From 729cb649fcaad4763056eb1b531b56028aaa4c1a Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 16 Feb 2026 21:02:05 +0100 Subject: [PATCH] add extra tools --- .tf5_mixer_cache/channels_cache.json | 9 +- .tf5_mixer_cache/mixer_ip.json | 2 +- __pycache__/mixer_controller.cpython-312.pyc | Bin 43777 -> 110130 bytes mixer_controller.py | 1465 +++++++++++++++--- 4 files changed, 1252 insertions(+), 224 deletions(-) diff --git a/.tf5_mixer_cache/channels_cache.json b/.tf5_mixer_cache/channels_cache.json index ef6a8da..3697599 100644 --- a/.tf5_mixer_cache/channels_cache.json +++ b/.tf5_mixer_cache/channels_cache.json @@ -403,5 +403,12 @@ "level_db": 6.0 } }, - "timestamp": 1770577942.7806144 + "timestamp": 1770577942.7806144, + "steinch": {}, + "fxrtn": {}, + "dcas": {}, + "matrices": {}, + "stereo": {}, + "mono": {}, + "mute_masters": {} } \ No newline at end of file diff --git a/.tf5_mixer_cache/mixer_ip.json b/.tf5_mixer_cache/mixer_ip.json index e39c941..51b1087 100644 --- a/.tf5_mixer_cache/mixer_ip.json +++ b/.tf5_mixer_cache/mixer_ip.json @@ -1,4 +1,4 @@ { "ip": "192.168.1.59", - "timestamp": 1771271281.9053261 + "timestamp": 1771271930.0475464 } \ No newline at end of file diff --git a/__pycache__/mixer_controller.cpython-312.pyc b/__pycache__/mixer_controller.cpython-312.pyc index 9f5e7d3aed5917c45af7fac92e554ade6364ccd4..4e91b65c8763ca9f50a589adbdfc96e0c60e7749 100644 GIT binary patch literal 110130 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 delta 15460 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 diff --git a/mixer_controller.py b/mixer_controller.py index 275ec24..58c21a0 100644 --- a/mixer_controller.py +++ b/mixer_controller.py @@ -1,5 +1,3 @@ -# mixer_controller.py - #!/usr/bin/env python # -*- coding: utf-8 -*- import socket @@ -46,7 +44,7 @@ class TF5MixerController: self._reader_thread = threading.Thread(target=self._connection_manager) self._reader_thread.daemon = True self._reader_thread.start() - + if not self._is_connected.wait(timeout=5): print("⚠️ Impossibile connettersi al mixer all'avvio.") else: @@ -65,7 +63,7 @@ class TF5MixerController: 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() @@ -117,12 +115,13 @@ class TF5MixerController: def _handle_notify(self, message: str): """ Analizza un messaggio NOTIFY e aggiorna la cache in modo robusto. - Versione che gestisce il formato esatto 'NOTIFY set ...' con parametri extra. + Gestisce InCh, StInCh, FxRtnCh, DCA, Mix, Mtrx, St, Mono, MuteMaster. """ print(f"RECV NOTIFY: {message}") - + parts = message.split() - if len(parts) < 2: return + if len(parts) < 2: + return path = None path_index = -1 @@ -131,9 +130,9 @@ class TF5MixerController: path = part path_index = i break - + if path is None: - print(f" -> ATTENZIONE: Nessun path valido (che inizia con 'MIXER:') trovato nel messaggio.") + print(f" -> ATTENZIONE: Nessun path valido trovato nel messaggio.") return print(f" -> Tentativo di acquisire il lock per l'aggiornamento (Path: {path})...") @@ -141,51 +140,227 @@ class TF5MixerController: print(f" -> LOCK ACQUISITO. Elaborazione...") try: updated = False - - # --- LOGICA DI PARSING SPECIFICA E ROBUSTA --- - # Per 'set' commands, i parametri sono: ch_idx, 0, value, [optional_string] - # Indice ch: path_index + 1 - # Indice valore: path_index + 3 - # Gestione NOME Canale (usa un parsing diverso) + # --- InCh --- if "InCh/Label/Name" in path: params = parts[path_index + 1:] if len(params) >= 2: ch_idx, name = int(params[0]), " ".join(params[1:]).strip('"') ch_key = str(ch_idx + 1) - - channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) - channel_data["name"] = name - print(f" -> SUCCESS: Ch {ch_key} nome -> '{name}'") + self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)})["name"] = name + print(f" -> SUCCESS: InCh {ch_key} nome -> '{name}'") updated = True - # Gestione ON/OFF Canale elif "InCh/Fader/On" in path: if len(parts) > path_index + 3: ch_idx = int(parts[path_index + 1]) - state_val = parts[path_index + 3] - state = state_val == "1" + state = parts[path_index + 3] == "1" ch_key = str(ch_idx + 1) - - channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) - channel_data["on"] = state - print(f" -> SUCCESS: Ch {ch_key} stato -> {'ON' if state else 'OFF'}") + self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)})["on"] = state + print(f" -> SUCCESS: InCh {ch_key} stato -> {'ON' if state else 'OFF'}") updated = True - # Gestione LIVELLO Canale elif "InCh/Fader/Level" in path: if len(parts) > path_index + 3: ch_idx = int(parts[path_index + 1]) - level_int_str = parts[path_index + 3] - level_int = int(level_int_str) + level_int = int(parts[path_index + 3]) ch_key = str(ch_idx + 1) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') - - channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) - channel_data["level_db"] = level_db - print(f" -> SUCCESS: Ch {ch_key} level -> {level_db:.2f} dB") + self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)})["level_db"] = level_db + print(f" -> SUCCESS: InCh {ch_key} level -> {level_db:.2f} dB") updated = True - + + # --- StInCh --- + elif "StInCh/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + ch_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + ch_key = str(ch_idx + 1) + self._cache.setdefault("steinch", {}).setdefault(ch_key, {"channel": int(ch_key)})["name"] = name + print(f" -> SUCCESS: StInCh {ch_key} nome -> '{name}'") + updated = True + + elif "StInCh/Fader/On" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + ch_key = str(ch_idx + 1) + self._cache.setdefault("steinch", {}).setdefault(ch_key, {"channel": int(ch_key)})["on"] = state + print(f" -> SUCCESS: StInCh {ch_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "StInCh/Fader/Level" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + ch_key = str(ch_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("steinch", {}).setdefault(ch_key, {"channel": int(ch_key)})["level_db"] = level_db + print(f" -> SUCCESS: StInCh {ch_key} level -> {level_db:.2f} dB") + updated = True + + # --- FxRtnCh --- + elif "FxRtnCh/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + ch_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + ch_key = str(ch_idx + 1) + self._cache.setdefault("fxrtn", {}).setdefault(ch_key, {"channel": int(ch_key)})["name"] = name + print(f" -> SUCCESS: FxRtnCh {ch_key} nome -> '{name}'") + updated = True + + elif "FxRtnCh/Fader/On" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + ch_key = str(ch_idx + 1) + self._cache.setdefault("fxrtn", {}).setdefault(ch_key, {"channel": int(ch_key)})["on"] = state + print(f" -> SUCCESS: FxRtnCh {ch_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "FxRtnCh/Fader/Level" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + ch_key = str(ch_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("fxrtn", {}).setdefault(ch_key, {"channel": int(ch_key)})["level_db"] = level_db + print(f" -> SUCCESS: FxRtnCh {ch_key} level -> {level_db:.2f} dB") + updated = True + + # --- DCA --- + elif "DCA/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + dca_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + dca_key = str(dca_idx + 1) + self._cache.setdefault("dcas", {}).setdefault(dca_key, {"dca": int(dca_key)})["name"] = name + print(f" -> SUCCESS: DCA {dca_key} nome -> '{name}'") + updated = True + + elif "DCA/Fader/On" in path: + if len(parts) > path_index + 3: + dca_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + dca_key = str(dca_idx + 1) + self._cache.setdefault("dcas", {}).setdefault(dca_key, {"dca": int(dca_key)})["on"] = state + print(f" -> SUCCESS: DCA {dca_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "DCA/Fader/Level" in path: + if len(parts) > path_index + 3: + dca_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + dca_key = str(dca_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("dcas", {}).setdefault(dca_key, {"dca": int(dca_key)})["level_db"] = level_db + print(f" -> SUCCESS: DCA {dca_key} level -> {level_db:.2f} dB") + updated = True + + # --- Mix --- + elif "Mix/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + mix_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + mix_key = str(mix_idx + 1) + self._cache.setdefault("mixes", {}).setdefault(mix_key, {"mix": int(mix_key)})["name"] = name + print(f" -> SUCCESS: Mix {mix_key} nome -> '{name}'") + updated = True + + elif "Mix/Fader/On" in path: + if len(parts) > path_index + 3: + mix_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + mix_key = str(mix_idx + 1) + self._cache.setdefault("mixes", {}).setdefault(mix_key, {"mix": int(mix_key)})["on"] = state + print(f" -> SUCCESS: Mix {mix_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "Mix/Fader/Level" in path: + if len(parts) > path_index + 3: + mix_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + mix_key = str(mix_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("mixes", {}).setdefault(mix_key, {"mix": int(mix_key)})["level_db"] = level_db + print(f" -> SUCCESS: Mix {mix_key} level -> {level_db:.2f} dB") + updated = True + + # --- Mtrx --- + elif "Mtrx/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + mtrx_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + mtrx_key = str(mtrx_idx + 1) + self._cache.setdefault("matrices", {}).setdefault(mtrx_key, {"matrix": int(mtrx_key)})["name"] = name + print(f" -> SUCCESS: Mtrx {mtrx_key} nome -> '{name}'") + updated = True + + elif "Mtrx/Fader/On" in path: + if len(parts) > path_index + 3: + mtrx_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + mtrx_key = str(mtrx_idx + 1) + self._cache.setdefault("matrices", {}).setdefault(mtrx_key, {"matrix": int(mtrx_key)})["on"] = state + print(f" -> SUCCESS: Mtrx {mtrx_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "Mtrx/Fader/Level" in path: + if len(parts) > path_index + 3: + mtrx_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + mtrx_key = str(mtrx_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("matrices", {}).setdefault(mtrx_key, {"matrix": int(mtrx_key)})["level_db"] = level_db + print(f" -> SUCCESS: Mtrx {mtrx_key} level -> {level_db:.2f} dB") + updated = True + + # --- Stereo Bus --- + elif "St/Fader/On" in path: + if len(parts) > path_index + 3: + st_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + st_key = str(st_idx + 1) + self._cache.setdefault("stereo", {}).setdefault(st_key, {"bus": int(st_key)})["on"] = state + print(f" -> SUCCESS: St {st_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "St/Fader/Level" in path: + if len(parts) > path_index + 3: + st_idx = int(parts[path_index + 1]) + level_int = int(parts[path_index + 3]) + st_key = str(st_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("stereo", {}).setdefault(st_key, {"bus": int(st_key)})["level_db"] = level_db + print(f" -> SUCCESS: St {st_key} level -> {level_db:.2f} dB") + updated = True + + # --- Mono Bus --- + elif "Mono/Fader/On" in path: + if len(parts) > path_index + 3: + state = parts[path_index + 3] == "1" + self._cache.setdefault("mono", {})["on"] = state + print(f" -> SUCCESS: Mono stato -> {'ON' if state else 'OFF'}") + updated = True + + elif "Mono/Fader/Level" in path: + if len(parts) > path_index + 3: + level_int = int(parts[path_index + 3]) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + self._cache.setdefault("mono", {})["level_db"] = level_db + print(f" -> SUCCESS: Mono level -> {level_db:.2f} dB") + updated = True + + # --- MuteMaster --- + elif "MuteMaster/On" in path: + if len(parts) > path_index + 3: + group_idx = int(parts[path_index + 1]) + state = parts[path_index + 3] == "1" + group_key = str(group_idx + 1) + self._cache.setdefault("mute_masters", {}).setdefault(group_key, {"group": int(group_key)})["on"] = state + print(f" -> SUCCESS: MuteMaster {group_key} stato -> {'ON' if state else 'OFF'}") + updated = True + if updated: self._cache['timestamp'] = time.time() else: @@ -195,7 +370,7 @@ class TF5MixerController: print(f" -> ERRORE CRITICO durante l'elaborazione: {e}") finally: print(f" -> LOCK RILASCIATO.") - + def _send_command(self, command: str) -> str: """Invia un comando al mixer e attende la risposta.""" if not self._is_connected.wait(timeout=5): @@ -208,7 +383,7 @@ class TF5MixerController: print(f"SEND: {command}") self.socket.sendall((command + '\n').encode('utf-8')) - + response = self._response_queue.get(timeout=5) print(f"RECV RESP: {response}") return response @@ -242,7 +417,10 @@ class TF5MixerController: def __exit__(self, exc_type, exc_val, exc_tb): self.close() - # --- Metodi di gestione della cache e di utilità (invariati) --- + # ========================================================================= + # UTILITY E CACHE + # ========================================================================= + def _sanitize_value(self, value): if value is None: return -120.0 if value == float('-inf') or (isinstance(value, float) and math.isinf(value) and value < 0): return -120.0 @@ -259,13 +437,14 @@ class TF5MixerController: try: with open(CACHE_FILE, 'r', encoding='utf-8') as f: data = json.load(f) - # Assicura che le chiavi principali esistano - if "channels" not in data: data["channels"] = {} - if "mixes" not in data: data["mixes"] = {} + 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": {}, "timestamp": 0} + return {"channels": {}, "mixes": {}, "steinch": {}, "fxrtn": {}, "dcas": {}, + "matrices": {}, "stereo": {}, "mono": {}, "mute_masters": {}, "timestamp": 0} def _save_cache(self): with self._cache_lock: @@ -293,7 +472,7 @@ class TF5MixerController: def _normalize_level_from_cache(self, level_value): if level_value is None: return float('-inf') return level_value - + def _parse_name(self, response: str) -> str: try: first_line = response.split('\n')[0] @@ -308,248 +487,794 @@ class TF5MixerController: parts = first_line.split() if len(parts) > 0 and parts[0] == "OK": return parts[-1] return "N/A" - - # --- API Pubblica del Controller (invariata) --- - # ... tutti gli altri metodi da refresh_cache in poi rimangono identici ... + + def _level_to_internal(self, level_db: float) -> int: + """Converte dB in valore interno intero.""" + return -32768 if level_db <= -138 else int(level_db * 100) + + def _internal_to_level(self, raw: str): + """Converte stringa valore interno in dB float.""" + try: + v = int(raw) + return v / 100.0 if v > -32768 else float('-inf') + except: + return None + + # ========================================================================= + # REFRESH CACHE COMPLETO + # ========================================================================= + def refresh_cache(self) -> dict: print("🔄 Aggiornamento cache completo in corso...") - channels_data = {} - mixes_data = {} + channels_data, mixes_data, steinch_data = {}, {}, {} + fxrtn_data, dcas_data, matrices_data = {}, {}, {} + stereo_data, mute_masters_data = {}, {} + mono_data = {} + # Input Channels for ch in range(1, TF5_INPUT_CHANNELS + 1): ch_idx = ch - 1 - resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") - name = self._parse_name(resp_name) - resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") - is_on = self._parse_value(resp_on) == "1" - resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") - level_raw = self._parse_value(resp_level) - try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') - except: level_db = None - resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") - pan_raw = self._parse_value(resp_pan) + 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) # Riduci il delay, la connessione persistente è veloce + 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) + + # 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) + + # 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) + + # Mix Buses for mix in range(1, TF5_MIX_BUSSES + 1): mix_idx = mix - 1 - resp_name = self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0") - name = self._parse_name(resp_name) - resp_on = self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0") - is_on = self._parse_value(resp_on) == "1" - resp_level = self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0") - level_raw = self._parse_value(resp_level) - try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') - except: level_db = None + 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) + # 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) + + # 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) + + # 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} + + # 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) + with self._cache_lock: - self._cache = {"channels": channels_data, "mixes": mixes_data, "timestamp": time.time()} + 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 con {len(channels_data)} canali e {len(mixes_data)} mix" + + 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, "channels_count": len(channels_data), "mixes_count": len(mixes_data)} + return {"status": "success", "message": msg} - # ... TUTTI GLI ALTRI METODI PUBBLICI RESTANO UGUALI ... - def recall_scene(self, bank: str, scene_number: int) -> dict: - if bank.lower() not in ['a', 'b']: return {"status": "error", "message": "Il banco deve essere 'a' o 'b'"} - if not 0 <= scene_number <= 99: return {"status": "error", "message": "Il numero scena deve essere tra 0 e 99"} - - command = f"ssrecall_ex scene_{bank.lower()} {scene_number}" - response = self._send_command(command) - - if "OK" in response: - print("Scene richiamata. La cache si aggiornerà tramite NOTIFY. Schedulato un refresh completo in 5s per sicurezza.") - threading.Timer(5.0, self.refresh_cache).start() - - return {"status": "success" if "OK" in response else "error", "message": f"Scena {bank.upper()}{scene_number} richiamata.", "response": response} + # ========================================================================= + # INPUT CHANNELS (InCh) + # ========================================================================= def set_channel_level(self, channel: int, level_db: float) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - internal_value = -32768 if level_db <= -138 else int(level_db * 100) - command = f"set MIXER:Current/InCh/Fader/Level {channel-1} 0 {internal_value}" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + command = f"set MIXER:Current/InCh/Fader/Level {channel-1} 0 {self._level_to_internal(level_db)}" response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Canale {channel} impostato a {level_db:+.1f} dB", "response": response} + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} impostato a {level_db:+.1f} dB", "response": response} def set_channel_on_off(self, channel: int, state: bool) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} command = f"set MIXER:Current/InCh/Fader/On {channel-1} 0 {1 if state else 0}" response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Canale {channel} {'acceso' if state else 'spento'}", "response": response} + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} {'acceso' if state else 'spento'}", "response": response} def set_channel_pan(self, channel: int, pan_value: int) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - if not -63 <= pan_value <= 63: return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} command = f"set MIXER:Current/InCh/ToSt/Pan {channel-1} 0 {pan_value}" response = self._send_command(command) - pan_desc = "centro" if pan_value < 0: pan_desc = f"sinistra {abs(pan_value)}" elif pan_value > 0: pan_desc = f"destra {pan_value}" - return {"status": "success" if "OK" in response else "error", "message": f"Canale {channel} pan impostato a {pan_desc}", "response": response} + else: pan_desc = "centro" + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} pan impostato a {pan_desc}", "response": response} - def set_mix_level(self, mix_number: int, level_db: float) -> dict: - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - internal_value = -32768 if level_db <= -138 else int(level_db * 100) - command = f"set MIXER:Current/Mix/Fader/Level {mix_number-1} 0 {internal_value}" + def set_channel_to_fx_level(self, channel: int, fx_number: int, level_db: float) -> dict: + """Imposta il livello di send di un canale di ingresso verso un processore FX.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= fx_number <= TF5_FX_SLOTS: + return {"status": "error", "message": f"Il numero FX deve essere tra 1 e {TF5_FX_SLOTS}"} + command = f"set MIXER:Current/InCh/ToFx/Level {channel-1} {fx_number-1} {self._level_to_internal(level_db)}" response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → FX {fx_number} impostato a {level_db:+.1f} dB", "response": response} - def set_mix_on_off(self, mix_number: int, state: bool) -> dict: - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - command = f"set MIXER:Current/Mix/Fader/On {mix_number-1} 0 {1 if state else 0}" + def set_channel_to_fx_on_off(self, channel: int, fx_number: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale di ingresso verso un processore FX.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= fx_number <= TF5_FX_SLOTS: + return {"status": "error", "message": f"Il numero FX deve essere tra 1 e {TF5_FX_SLOTS}"} + command = f"set MIXER:Current/InCh/ToFx/On {channel-1} {fx_number-1} {1 if state else 0}" response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Mix {mix_number} {'acceso' if state else 'spento'}", "response": response} + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → FX {fx_number} send {'acceso' if state else 'spento'}", "response": response} - def mute_multiple_channels(self, channels: List[int]) -> dict: - results = [self.set_channel_on_off(ch, False) for ch in channels] - success_count = sum(1 for r in results if r["status"] == "success") - return {"status": "success" if success_count == len(channels) else "partial", "message": f"Mutati {success_count}/{len(channels)} canali: {channels}", "details": results} + def set_channel_to_fx_prepost(self, channel: int, fx_number: int, post_fader: bool) -> dict: + """Imposta pre/post fader il send di un canale di ingresso verso un processore FX.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= fx_number <= TF5_FX_SLOTS: + return {"status": "error", "message": f"Il numero FX deve essere tra 1 e {TF5_FX_SLOTS}"} + command = f"set MIXER:Current/InCh/ToFx/PrePost {channel-1} {fx_number-1} {1 if post_fader else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → FX {fx_number} impostato a {'post' if post_fader else 'pre'} fader", "response": response} - def unmute_multiple_channels(self, channels: List[int]) -> dict: - results = [self.set_channel_on_off(ch, True) for ch in channels] - success_count = sum(1 for r in results if r["status"] == "success") - return {"status": "success" if success_count == len(channels) else "partial", "message": f"Riattivati {success_count}/{len(channels)} canali: {channels}", "details": results} + def set_channel_to_mono_level(self, channel: int, level_db: float) -> dict: + """Imposta il livello di send di un canale di ingresso verso il bus Mono.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + command = f"set MIXER:Current/InCh/ToMono/Level {channel-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → Mono impostato a {level_db:+.1f} dB", "response": response} + + def set_channel_to_mono_on_off(self, channel: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale di ingresso verso il bus Mono.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + command = f"set MIXER:Current/InCh/ToMono/On {channel-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → Mono send {'acceso' if state else 'spento'}", "response": response} + + def set_channel_to_mix_level(self, channel: int, mix_number: int, level_db: float) -> dict: + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/InCh/ToMix/Level {channel-1} {mix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_channel_to_mix_on_off(self, channel: int, mix_number: int, state: bool) -> dict: + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/InCh/ToMix/On {channel-1} {mix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Invio canale {channel} → Mix {mix_number} {'acceso' if state else 'spento'}", "response": response} + + def set_channel_to_mix_pan(self, channel: int, mix_number: int, pan_value: int) -> dict: + """Imposta il pan del send di un canale verso un Mix bus.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + command = f"set MIXER:Current/InCh/ToMix/Pan {channel-1} {mix_number-1} {pan_value}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → Mix {mix_number} pan impostato a {pan_value}", "response": response} + + def set_channel_to_mix_prepost(self, channel: int, mix_number: int, post_fader: bool) -> dict: + """Imposta pre/post fader il send di un canale verso un Mix bus.""" + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/InCh/ToMix/PrePost {channel-1} {mix_number-1} {1 if post_fader else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Canale {channel} → Mix {mix_number} impostato a {'post' if post_fader else 'pre'} fader", "response": response} def get_channel_info(self, channel: int, force_refresh: bool = False) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} if not force_refresh and self._is_cache_valid(): with self._cache_lock: cached_data = self._cache.get("channels", {}).get(str(channel)) if cached_data: - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db"))) - return {"status": "success", "source": "cache", "channel": cached_data["channel"], "name": cached_data["name"], "on": cached_data["on"], "level_db": sanitized_level_db, "pan": cached_data.get("pan")} - + return {"status": "success", "source": "cache", + "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")} + ch_idx = channel - 1 - resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") - name = self._parse_name(resp_name) - resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") - is_on = self._parse_value(resp_on) == "1" - resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") - level_raw = self._parse_value(resp_level) - try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') - except: level_db = None - resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") - pan_raw = self._parse_value(resp_pan) + 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 - - with self._cache_lock: - if "channels" not in self._cache: self._cache["channels"] = {} - self._cache["channels"][str(channel)] = {"channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} - self._cache['timestamp'] = time.time() - - return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, "level_db": self._sanitize_value(level_db), "pan": pan_value} - def get_mix_info(self, mix_number: int, force_refresh: bool = False) -> dict: - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - if not force_refresh and self._is_cache_valid(): - with self._cache_lock: - cached_data = self._cache.get("mixes", {}).get(str(mix_number)) - if cached_data: - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db"))) - return {"status": "success", "source": "cache", "mix": cached_data["mix"], "name": cached_data["name"], "on": cached_data["on"], "level_db": sanitized_level_db} - - mix_idx = mix_number - 1 - resp_name = self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0") - name = self._parse_name(resp_name) - resp_on = self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0") - is_on = self._parse_value(resp_on) == "1" - resp_level = self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0") - level_raw = self._parse_value(resp_level) - try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') - except: level_db = None - with self._cache_lock: - if "mixes" not in self._cache: self._cache["mixes"] = {} - self._cache["mixes"][str(mix_number)] = {"mix": mix_number, "name": name, "on": is_on, "level_db": level_db} + self._cache.setdefault("channels", {})[str(channel)] = { + "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} self._cache['timestamp'] = time.time() - return {"status": "success", "source": "mixer", "mix": mix_number, "name": name, "on": is_on, "level_db": self._sanitize_value(level_db)} + return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db), "pan": pan_value} + + def get_channel_to_mix_info(self, channel: int, mix_number: int) -> dict: + if not 1 <= channel <= TF5_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + ch_idx, mix_idx = channel - 1, mix_number - 1 + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/InCh/ToMix/Level {ch_idx} {mix_idx}"))) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/InCh/ToMix/On {ch_idx} {mix_idx}")) == "1" + sanitized = self._sanitize_value(level_db) + return {"status": "success", "channel": channel, "mix": mix_number, "on": is_on, + "send_level_db": sanitized, + "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() + with self._cache_lock: + channels_copy = list(self._cache.get("channels", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + channels = sorted([ + {"channel": info["channel"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in channels_copy + ], key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(channels), "channels": channels, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(channels)} canali (cache: {int(cache_age)}s fa)"} + + def mute_multiple_channels(self, channels: List[int]) -> dict: + results = [self.set_channel_on_off(ch, False) for ch in channels] + success_count = sum(1 for r in results if r["status"] == "success") + return {"status": "success" if success_count == len(channels) else "partial", + "message": f"Mutati {success_count}/{len(channels)} canali: {channels}", "details": results} + + def unmute_multiple_channels(self, channels: List[int]) -> dict: + results = [self.set_channel_on_off(ch, True) for ch in channels] + success_count = sum(1 for r in results if r["status"] == "success") + return {"status": "success" if success_count == len(channels) else "partial", + "message": f"Riattivati {success_count}/{len(channels)} canali: {channels}", "details": results} def search_channels_by_name(self, search_term: str) -> dict: if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() - found_channels = [] with self._cache_lock: channels_copy = list(self._cache.get("channels", {}).values()) - for info in channels_copy: - if search_lower in info.get("name", "").lower(): - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) - found_channels.append({"channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) - found_channels.sort(key=lambda x: x["channel"]) - return {"status": "success", "search_term": search_term, "found_count": len(found_channels), "channels": found_channels, "message": f"Trovati {len(found_channels)} canali contenenti '{search_term}'"} + found = sorted([ + {"channel": info["channel"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in channels_copy if search_lower in info.get("name", "").lower() + ], key=lambda x: x["channel"]) + return {"status": "success", "search_term": search_term, "found_count": len(found), "channels": found, + "message": f"Trovati {len(found)} canali contenenti '{search_term}'"} + + # ========================================================================= + # STEREO INPUT CHANNELS (StInCh) + # ========================================================================= + + def get_steinch_info(self, channel: int, force_refresh: bool = False) -> dict: + """Restituisce le informazioni di un canale stereo di ingresso.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("steinch", {}).get(str(channel)) + if cached_data: + return {"status": "success", "source": "cache", + "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")} + + ch_idx = channel - 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 + + with self._cache_lock: + self._cache.setdefault("steinch", {})[str(channel)] = { + "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db), "pan": pan_value} + + def set_steinch_level(self, channel: int, level_db: float) -> dict: + """Imposta il livello fader di un canale stereo di ingresso.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + command = f"set MIXER:Current/StInCh/Fader/Level {channel-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} impostato a {level_db:+.1f} dB", "response": response} + + def set_steinch_on_off(self, channel: int, state: bool) -> dict: + """Abilita/disabilita un canale stereo di ingresso.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + command = f"set MIXER:Current/StInCh/Fader/On {channel-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} {'acceso' if state else 'spento'}", "response": response} + + def set_steinch_pan(self, channel: int, pan_value: int) -> dict: + """Imposta il pan di un canale stereo di ingresso verso il bus Stereo.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + command = f"set MIXER:Current/StInCh/ToSt/Pan {channel-1} 0 {pan_value}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} pan impostato a {pan_value}", "response": response} + + def set_steinch_to_mix_level(self, channel: int, mix_number: int, level_db: float) -> dict: + """Imposta il livello di send di un canale stereo verso un Mix bus.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/StInCh/ToMix/Level {channel-1} {mix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_steinch_to_mix_on_off(self, channel: int, mix_number: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale stereo verso un Mix bus.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/StInCh/ToMix/On {channel-1} {mix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → Mix {mix_number} send {'acceso' if state else 'spento'}", "response": response} + + def set_steinch_to_mix_pan(self, channel: int, mix_number: int, pan_value: int) -> dict: + """Imposta il pan del send di un canale stereo verso un Mix bus.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + command = f"set MIXER:Current/StInCh/ToMix/Pan {channel-1} {mix_number-1} {pan_value}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → Mix {mix_number} pan impostato a {pan_value}", "response": response} + + def set_steinch_to_fx_level(self, channel: int, fx_number: int, level_db: float) -> dict: + """Imposta il livello di send di un canale stereo verso un processore FX.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not 1 <= fx_number <= TF5_FX_SLOTS: + return {"status": "error", "message": f"Il numero FX deve essere tra 1 e {TF5_FX_SLOTS}"} + command = f"set MIXER:Current/StInCh/ToFx/Level {channel-1} {fx_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → FX {fx_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_steinch_to_fx_on_off(self, channel: int, fx_number: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale stereo verso un processore FX.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + if not 1 <= fx_number <= TF5_FX_SLOTS: + return {"status": "error", "message": f"Il numero FX deve essere tra 1 e {TF5_FX_SLOTS}"} + command = f"set MIXER:Current/StInCh/ToFx/On {channel-1} {fx_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → FX {fx_number} send {'acceso' if state else 'spento'}", "response": response} + + def set_steinch_to_mono_level(self, channel: int, level_db: float) -> dict: + """Imposta il livello di send di un canale stereo verso il bus Mono.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + command = f"set MIXER:Current/StInCh/ToMono/Level {channel-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → Mono impostato a {level_db:+.1f} dB", "response": response} + + def set_steinch_to_mono_on_off(self, channel: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale stereo verso il bus Mono.""" + if not 1 <= channel <= TF5_ST_INPUT_CHANNELS: + return {"status": "error", "message": f"Il canale stereo deve essere tra 1 e {TF5_ST_INPUT_CHANNELS}"} + command = f"set MIXER:Current/StInCh/ToMono/On {channel-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"StInCh {channel} → Mono send {'acceso' if state else 'spento'}", "response": response} + + def get_all_steinch_summary(self) -> dict: + """Restituisce il riepilogo di tutti i canali stereo di ingresso dalla cache.""" + if not self._is_cache_valid(): self.refresh_cache() + with self._cache_lock: + steinch_copy = list(self._cache.get("steinch", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + channels = sorted([ + {"channel": info["channel"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in steinch_copy + ], key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(channels), "channels": channels, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(channels)} canali stereo (cache: {int(cache_age)}s fa)"} + + # ========================================================================= + # FX RETURN CHANNELS (FxRtnCh) + # ========================================================================= + + def get_fxrtn_info(self, channel: int, force_refresh: bool = False) -> dict: + """Restituisce le informazioni di un canale di ritorno FX.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("fxrtn", {}).get(str(channel)) + if cached_data: + return {"status": "success", "source": "cache", + "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")))} + + ch_idx = channel - 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"))) + + with self._cache_lock: + self._cache.setdefault("fxrtn", {})[str(channel)] = { + "channel": channel, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_fxrtn_level(self, channel: int, level_db: float) -> dict: + """Imposta il livello fader di un canale FX return.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + command = f"set MIXER:Current/FxRtnCh/Fader/Level {channel-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} impostato a {level_db:+.1f} dB", "response": response} + + def set_fxrtn_on_off(self, channel: int, state: bool) -> dict: + """Abilita/disabilita un canale FX return.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + command = f"set MIXER:Current/FxRtnCh/Fader/On {channel-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} {'acceso' if state else 'spento'}", "response": response} + + def set_fxrtn_to_mix_level(self, channel: int, mix_number: int, level_db: float) -> dict: + """Imposta il livello di send di un canale FX return verso un Mix bus.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/FxRtnCh/ToMix/Level {channel-1} {mix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_fxrtn_to_mix_on_off(self, channel: int, mix_number: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale FX return verso un Mix bus.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/FxRtnCh/ToMix/On {channel-1} {mix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → Mix {mix_number} send {'acceso' if state else 'spento'}", "response": response} + + def set_fxrtn_to_mix_pan(self, channel: int, mix_number: int, pan_value: int) -> dict: + """Imposta il pan del send di un canale FX return verso un Mix bus.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + command = f"set MIXER:Current/FxRtnCh/ToMix/Pan {channel-1} {mix_number-1} {pan_value}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → Mix {mix_number} pan impostato a {pan_value}", "response": response} + + def set_fxrtn_to_mono_level(self, channel: int, level_db: float) -> dict: + """Imposta il livello di send di un canale FX return verso il bus Mono.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + command = f"set MIXER:Current/FxRtnCh/ToMono/Level {channel-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → Mono impostato a {level_db:+.1f} dB", "response": response} + + def set_fxrtn_to_mono_on_off(self, channel: int, state: bool) -> dict: + """Abilita/disabilita il send di un canale FX return verso il bus Mono.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + command = f"set MIXER:Current/FxRtnCh/ToMono/On {channel-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → Mono send {'acceso' if state else 'spento'}", "response": response} + + def set_fxrtn_to_st_pan(self, channel: int, pan_value: int) -> dict: + """Imposta il pan di un canale FX return verso il bus Stereo.""" + if not 1 <= channel <= TF5_FX_RETURN_CHANNELS: + return {"status": "error", "message": f"Il canale FX return deve essere tra 1 e {TF5_FX_RETURN_CHANNELS}"} + if not -63 <= pan_value <= 63: + return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} + command = f"set MIXER:Current/FxRtnCh/ToSt/Pan {channel-1} 0 {pan_value}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"FxRtnCh {channel} → St pan impostato a {pan_value}", "response": response} + + def get_all_fxrtn_summary(self) -> dict: + """Restituisce il riepilogo di tutti i canali FX return dalla cache.""" + if not self._is_cache_valid(): self.refresh_cache() + with self._cache_lock: + fxrtn_copy = list(self._cache.get("fxrtn", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + channels = sorted([ + {"channel": info["channel"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in fxrtn_copy + ], key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(channels), "channels": channels, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(channels)} canali FX return (cache: {int(cache_age)}s fa)"} + + # ========================================================================= + # DCA GROUPS + # ========================================================================= + + def get_dca_info(self, dca: int, force_refresh: bool = False) -> dict: + """Restituisce le informazioni di un gruppo DCA.""" + if not 1 <= dca <= TF5_DCA_GROUPS: + return {"status": "error", "message": f"Il DCA deve essere tra 1 e {TF5_DCA_GROUPS}"} + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("dcas", {}).get(str(dca)) + if cached_data: + return {"status": "success", "source": "cache", + "dca": cached_data["dca"], "name": cached_data["name"], + "on": cached_data["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db")))} + + 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"))) + + with self._cache_lock: + self._cache.setdefault("dcas", {})[str(dca)] = { + "dca": dca, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "dca": dca, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_dca_level(self, dca: int, level_db: float) -> dict: + """Imposta il livello fader di un gruppo DCA.""" + if not 1 <= dca <= TF5_DCA_GROUPS: + return {"status": "error", "message": f"Il DCA deve essere tra 1 e {TF5_DCA_GROUPS}"} + command = f"set MIXER:Current/DCA/Fader/Level {dca-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"DCA {dca} impostato a {level_db:+.1f} dB", "response": response} + + def set_dca_on_off(self, dca: int, state: bool) -> dict: + """Abilita/disabilita un gruppo DCA.""" + if not 1 <= dca <= TF5_DCA_GROUPS: + return {"status": "error", "message": f"Il DCA deve essere tra 1 e {TF5_DCA_GROUPS}"} + command = f"set MIXER:Current/DCA/Fader/On {dca-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"DCA {dca} {'acceso' if state else 'spento'}", "response": response} + + 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() + with self._cache_lock: + dcas_copy = list(self._cache.get("dcas", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + dcas = sorted([ + {"dca": info["dca"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in dcas_copy + ], key=lambda x: x["dca"]) + return {"status": "success", "total_dcas": len(dcas), "dcas": dcas, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(dcas)} DCA (cache: {int(cache_age)}s fa)"} + + # ========================================================================= + # MIX BUSES + # ========================================================================= + + def get_mix_info(self, mix_number: int, force_refresh: bool = False) -> 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 force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("mixes", {}).get(str(mix_number)) + if cached_data: + return {"status": "success", "source": "cache", + "mix": cached_data["mix"], "name": cached_data["name"], + "on": cached_data["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db")))} + + mix_idx = mix_number - 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"))) + + with self._cache_lock: + self._cache.setdefault("mixes", {})[str(mix_number)] = { + "mix": mix_number, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "mix": mix_number, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_mix_level(self, mix_number: int, level_db: float) -> dict: + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/Mix/Fader/Level {mix_number-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_mix_on_off(self, mix_number: int, state: bool) -> dict: + if not 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/Mix/Fader/On {mix_number-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mix {mix_number} {'acceso' if state else 'spento'}", "response": response} + + def set_mix_out_balance(self, mix_number: int, balance: int) -> dict: + """Imposta il balance di uscita di un Mix bus stereo.""" + 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 -63 <= balance <= 63: + return {"status": "error", "message": "Il balance deve essere tra -63 e +63"} + command = f"set MIXER:Current/Mix/Out/Balance {mix_number-1} 0 {balance}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mix {mix_number} balance impostato a {balance}", "response": response} + + def set_mix_to_mtrx_level(self, mix_number: int, matrix_number: int, level_db: float) -> dict: + """Imposta il livello di send da un Mix bus verso un bus Matrix.""" + 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 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mix/ToMtrx/Level {mix_number-1} {matrix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mix {mix_number} → Matrix {matrix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_mix_to_mtrx_on_off(self, mix_number: int, matrix_number: int, state: bool) -> dict: + """Abilita/disabilita il send da un Mix bus verso un bus Matrix.""" + 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 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mix/ToMtrx/On {mix_number-1} {matrix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "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() + with self._cache_lock: + mixes_copy = list(self._cache.get("mixes", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + mixes = sorted([ + {"mix": info["mix"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in mixes_copy + ], key=lambda x: x["mix"]) + return {"status": "success", "total_mixes": len(mixes), "mixes": mixes, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(mixes)} mix (cache: {int(cache_age)}s fa)"} def search_mixes_by_name(self, search_term: str) -> dict: if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() - found_mixes = [] with self._cache_lock: mixes_copy = list(self._cache.get("mixes", {}).values()) - for info in mixes_copy: - if search_lower in info.get("name", "").lower(): - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) - found_mixes.append({"mix": info["mix"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) - found_mixes.sort(key=lambda x: x["mix"]) - return {"status": "success", "search_term": search_term, "found_count": len(found_mixes), "mixes": found_mixes, "message": f"Trovati {len(found_mixes)} mix contenenti '{search_term}'"} - - def get_all_channels_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() - channels = [] - with self._cache_lock: - channels_copy = list(self._cache.get("channels", {}).values()) - cache_age = time.time() - self._cache.get("timestamp", 0) - for info in channels_copy: - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) - channels.append({"channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) - channels.sort(key=lambda x: x["channel"]) - return {"status": "success", "total_channels": len(channels), "channels": channels, "cache_age_seconds": int(cache_age), "message": f"Riepilogo di {len(channels)} canali (cache: {int(cache_age)}s fa)"} - - def get_all_mixes_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() - mixes = [] - with self._cache_lock: - mixes_copy = list(self._cache.get("mixes", {}).values()) - cache_age = time.time() - self._cache.get("timestamp", 0) - for info in mixes_copy: - sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) - mixes.append({"mix": info["mix"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) - mixes.sort(key=lambda x: x["mix"]) - return {"status": "success", "total_mixes": len(mixes), "mixes": mixes, "cache_age_seconds": int(cache_age), "message": f"Riepilogo di {len(mixes)} mix (cache: {int(cache_age)}s fa)"} - - def set_channel_to_mix_level(self, channel: int, mix_number: int, level_db: float) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - internal_value = -32768 if level_db <= -138 else int(level_db * 100) - command = f"set MIXER:Current/InCh/ToMix/Level {channel-1} {mix_number-1} {internal_value}" - response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Canale {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} - - def set_channel_to_mix_on_off(self, channel: int, mix_number: int, state: bool) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - command = f"set MIXER:Current/InCh/ToMix/On {channel-1} {mix_number-1} {1 if state else 0}" - response = self._send_command(command) - return {"status": "success" if "OK" in response else "error", "message": f"Invio canale {channel} → Mix {mix_number} {'acceso' if state else 'spento'}", "response": response} - - def get_channel_to_mix_info(self, channel: int, mix_number: int) -> dict: - if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - ch_idx, mix_idx = channel - 1, mix_number - 1 - resp_level = self._send_command(f"get MIXER:Current/InCh/ToMix/Level {ch_idx} {mix_idx}") - level_raw = self._parse_value(resp_level) - try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') - except: level_db = None - resp_on = self._send_command(f"get MIXER:Current/InCh/ToMix/On {ch_idx} {mix_idx}") - is_on = self._parse_value(resp_on) == "1" - sanitized_level_db = self._sanitize_value(level_db) - return {"status": "success", "channel": channel, "mix": mix_number, "on": is_on, "send_level_db": sanitized_level_db, "message": f"Canale {channel} → Mix {mix_number}: {sanitized_level_db:+.1f} dB ({'ON' if is_on else 'OFF'})"} + found = sorted([ + {"mix": info["mix"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in mixes_copy if search_lower in info.get("name", "").lower() + ], key=lambda x: x["mix"]) + return {"status": "success", "search_term": search_term, "found_count": len(found), "mixes": found, + "message": f"Trovati {len(found)} mix contenenti '{search_term}'"} 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 1 <= mix_number <= TF5_MIX_BUSSES: + return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} if not self._is_cache_valid(): self.refresh_cache() mix_info = self.get_mix_info(mix_number) if mix_info["status"] != "success": return mix_info @@ -559,6 +1284,302 @@ class TF5MixerController: if send_info["status"] == "success" and send_info["send_level_db"] > -120.0: with self._cache_lock: channel_cache = self._cache.get("channels", {}).get(str(channel), {}) - sends.append({"channel": channel, "channel_name": channel_cache.get("name", "Sconosciuto"), "channel_is_on": channel_cache.get("on", False), "send_level_db": send_info["send_level_db"], "send_is_on": send_info["on"]}) + sends.append({"channel": channel, + "channel_name": channel_cache.get("name", "Sconosciuto"), + "channel_is_on": channel_cache.get("on", False), + "send_level_db": send_info["send_level_db"], + "send_is_on": send_info["on"]}) sends.sort(key=lambda x: x["send_level_db"], reverse=True) - return {"status": "success", "mix_details": mix_info, "active_sends": sends, "message": f"Dettagli completi per il Mix {mix_number} recuperati."} \ No newline at end of file + return {"status": "success", "mix_details": mix_info, "active_sends": sends, + "message": f"Dettagli completi per il Mix {mix_number} recuperati."} + + # ========================================================================= + # MATRIX BUSES (Mtrx) + # ========================================================================= + + def get_mtrx_info(self, matrix: int, force_refresh: bool = False) -> dict: + """Restituisce le informazioni di un bus Matrix.""" + if not 1 <= matrix <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("matrices", {}).get(str(matrix)) + if cached_data: + return {"status": "success", "source": "cache", + "matrix": cached_data["matrix"], "name": cached_data["name"], + "on": cached_data["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db")))} + + mtrx_idx = matrix - 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"))) + + with self._cache_lock: + self._cache.setdefault("matrices", {})[str(matrix)] = { + "matrix": matrix, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "matrix": matrix, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_mtrx_level(self, matrix: int, level_db: float) -> dict: + """Imposta il livello fader di un bus Matrix.""" + if not 1 <= matrix <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mtrx/Fader/Level {matrix-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Matrix {matrix} impostata a {level_db:+.1f} dB", "response": response} + + def set_mtrx_on_off(self, matrix: int, state: bool) -> dict: + """Abilita/disabilita un bus Matrix.""" + if not 1 <= matrix <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mtrx/Fader/On {matrix-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Matrix {matrix} {'accesa' if state else 'spenta'}", "response": response} + + 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() + with self._cache_lock: + matrices_copy = list(self._cache.get("matrices", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + matrices = sorted([ + {"matrix": info["matrix"], "name": info["name"], "on": info["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} + for info in matrices_copy + ], key=lambda x: x["matrix"]) + return {"status": "success", "total_matrices": len(matrices), "matrices": matrices, + "cache_age_seconds": int(cache_age), + "message": f"Riepilogo di {len(matrices)} Matrix bus (cache: {int(cache_age)}s fa)"} + + # ========================================================================= + # STEREO BUS (St) + # ========================================================================= + + def get_stereo_info(self, bus: int = 1, force_refresh: bool = False) -> dict: + """Restituisce le informazioni di un bus Stereo (1=L, 2=R).""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("stereo", {}).get(str(bus)) + if cached_data: + return {"status": "success", "source": "cache", + "bus": cached_data["bus"], "name": cached_data["name"], + "on": cached_data["on"], + "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db")))} + + bus_idx = bus - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/St/Label/Name {bus_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/On {bus_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/Level {bus_idx} 0"))) + + with self._cache_lock: + self._cache.setdefault("stereo", {})[str(bus)] = { + "bus": bus, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "bus": bus, "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_stereo_level(self, bus: int, level_db: float) -> dict: + """Imposta il livello fader del bus Stereo.""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + command = f"set MIXER:Current/St/Fader/Level {bus-1} 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"St bus {bus} impostato a {level_db:+.1f} dB", "response": response} + + def set_stereo_on_off(self, bus: int, state: bool) -> dict: + """Abilita/disabilita il bus Stereo.""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + command = f"set MIXER:Current/St/Fader/On {bus-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"St bus {bus} {'acceso' if state else 'spento'}", "response": response} + + def set_stereo_balance(self, bus: int, balance: int) -> dict: + """Imposta il balance di uscita del bus Stereo.""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + if not -63 <= balance <= 63: + return {"status": "error", "message": "Il balance deve essere tra -63 e +63"} + command = f"set MIXER:Current/St/Out/Balance {bus-1} 0 {balance}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"St bus {bus} balance impostato a {balance}", "response": response} + + def set_stereo_to_mtrx_level(self, bus: int, matrix_number: int, level_db: float) -> dict: + """Imposta il livello di send dal bus Stereo verso un bus Matrix.""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + if not 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/St/ToMtrx/Level {bus-1} {matrix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"St bus {bus} → Matrix {matrix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_stereo_to_mtrx_on_off(self, bus: int, matrix_number: int, state: bool) -> dict: + """Abilita/disabilita il send dal bus Stereo verso un bus Matrix.""" + if not 1 <= bus <= TF5_STEREO_BUSSES: + return {"status": "error", "message": f"Il bus stereo deve essere tra 1 e {TF5_STEREO_BUSSES}"} + if not 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/St/ToMtrx/On {bus-1} {matrix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"St bus {bus} → Matrix {matrix_number} send {'acceso' if state else 'spento'}", "response": response} + + # ========================================================================= + # MONO BUS + # ========================================================================= + + def get_mono_info(self, force_refresh: bool = False) -> dict: + """Restituisce le informazioni del bus Mono.""" + if not force_refresh and self._is_cache_valid(): + with self._cache_lock: + cached_data = self._cache.get("mono", {}) + if cached_data: + return {"status": "success", "source": "cache", + "name": cached_data.get("name", "MONO"), + "on": cached_data.get("on", False), + "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db")))} + + 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"))) + + with self._cache_lock: + self._cache["mono"] = {"name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "name": name, "on": is_on, + "level_db": self._sanitize_value(level_db)} + + def set_mono_level(self, level_db: float) -> dict: + """Imposta il livello fader del bus Mono.""" + command = f"set MIXER:Current/Mono/Fader/Level 0 0 {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mono impostato a {level_db:+.1f} dB", "response": response} + + def set_mono_on_off(self, state: bool) -> dict: + """Abilita/disabilita il bus Mono.""" + command = f"set MIXER:Current/Mono/Fader/On 0 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mono {'acceso' if state else 'spento'}", "response": response} + + def set_mono_to_mtrx_level(self, matrix_number: int, level_db: float) -> dict: + """Imposta il livello di send dal bus Mono verso un bus Matrix.""" + if not 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mono/ToMtrx/Level 0 {matrix_number-1} {self._level_to_internal(level_db)}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mono → Matrix {matrix_number} impostato a {level_db:+.1f} dB", "response": response} + + def set_mono_to_mtrx_on_off(self, matrix_number: int, state: bool) -> dict: + """Abilita/disabilita il send dal bus Mono verso un bus Matrix.""" + if not 1 <= matrix_number <= TF5_MATRIX_BUSSES: + return {"status": "error", "message": f"La matrix deve essere tra 1 e {TF5_MATRIX_BUSSES}"} + command = f"set MIXER:Current/Mono/ToMtrx/On 0 {matrix_number-1} {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mono → Matrix {matrix_number} send {'acceso' if state else 'spento'}", "response": response} + + # ========================================================================= + # MUTE MASTERS + # ========================================================================= + + def get_mute_master_state(self, group: int) -> dict: + """Restituisce lo stato di un Mute Master group.""" + if not 1 <= group <= TF5_MUTE_GROUPS: + return {"status": "error", "message": f"Il gruppo mute deve essere tra 1 e {TF5_MUTE_GROUPS}"} + with self._cache_lock: + cached = self._cache.get("mute_masters", {}).get(str(group)) + if cached: + return {"status": "success", "source": "cache", + "group": cached["group"], "name": cached.get("name", f"MUTE {group}"), + "on": cached["on"]} + + is_on = self._parse_value(self._send_command(f"get MIXER:Current/MuteMaster/On {group-1} 0")) == "1" + name = self._parse_name(self._send_command(f"get MIXER:Current/MuteMaster/Label/Name {group-1} 0")) + with self._cache_lock: + self._cache.setdefault("mute_masters", {})[str(group)] = {"group": group, "name": name, "on": is_on} + + return {"status": "success", "source": "mixer", "group": group, "name": name, "on": is_on} + + def set_mute_master(self, group: int, state: bool) -> dict: + """Attiva/disattiva un Mute Master group.""" + if not 1 <= group <= TF5_MUTE_GROUPS: + return {"status": "error", "message": f"Il gruppo mute deve essere tra 1 e {TF5_MUTE_GROUPS}"} + command = f"set MIXER:Current/MuteMaster/On {group-1} 0 {1 if state else 0}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Mute Master {group} {'attivato' if state else 'disattivato'}", "response": response} + + def get_all_mute_masters_summary(self) -> dict: + """Restituisce lo stato di tutti i Mute Master groups dalla cache.""" + groups = [] + for g in range(1, TF5_MUTE_GROUPS + 1): + info = self.get_mute_master_state(g) + if info["status"] == "success": + groups.append({"group": info["group"], "name": info["name"], "on": info["on"]}) + return {"status": "success", "total_groups": len(groups), "groups": groups, + "message": f"Riepilogo di {len(groups)} Mute Master groups"} + + # ========================================================================= + # SCENE MANAGEMENT + # ========================================================================= + + def recall_scene(self, bank: str, scene_number: int) -> dict: + """Richiama una scena da un banco (a o b).""" + if bank.lower() not in ['a', 'b']: + return {"status": "error", "message": "Il banco deve essere 'a' o 'b'"} + if not 0 <= scene_number <= 99: + return {"status": "error", "message": "Il numero scena deve essere tra 0 e 99"} + + command = f"ssrecall_ex scene_{bank.lower()} {scene_number}" + response = self._send_command(command) + + if "OK" in response: + print("Scene richiamata. La cache si aggiornerà tramite NOTIFY. Schedulato un refresh completo in 5s per sicurezza.") + threading.Timer(5.0, self.refresh_cache).start() + + return {"status": "success" if "OK" in response else "error", + "message": f"Scena {bank.upper()}{scene_number} richiamata.", "response": response} + + def recall_scene_inc(self) -> dict: + """Richiama la scena successiva nella sequenza.""" + response = self._send_command("set MIXER:Lib/Scene/RecallInc 0 0") + if "OK" in response: + threading.Timer(5.0, self.refresh_cache).start() + return {"status": "success" if "OK" in response else "error", + "message": "Scena successiva richiamata.", "response": response} + + def recall_scene_dec(self) -> dict: + """Richiama la scena precedente nella sequenza.""" + response = self._send_command("set MIXER:Lib/Scene/RecallDec 0 0") + if "OK" in response: + threading.Timer(5.0, self.refresh_cache).start() + return {"status": "success" if "OK" in response else "error", + "message": "Scena precedente richiamata.", "response": response} + + def store_scene(self, bank: str, scene_number: int) -> dict: + """Salva la scena corrente in un banco (a o b).""" + if bank.lower() not in ['a', 'b']: + return {"status": "error", "message": "Il banco deve essere 'a' o 'b'"} + if not 0 <= scene_number <= 99: + return {"status": "error", "message": "Il numero scena deve essere tra 0 e 99"} + command = f"set MIXER:Lib/Bank/Scene/Store {1 if bank.lower() == 'b' else 0} {scene_number}" + response = self._send_command(command) + return {"status": "success" if "OK" in response else "error", + "message": f"Scena salvata nel banco {bank.upper()}{scene_number}.", "response": response} \ No newline at end of file