import asyncio import tkinter as tk from tkinter import ttk, messagebox # Import messagebox for confirmations import json import os from async_tkinter_loop import async_handler, async_mainloop from networking import WebSocketClient import color_utils import mido # Import mido for MIDI port detection import time from midi import MidiHandler # Import MidiHandler # Configuration file path CONFIG_FILE = "config.json" # Dark theme colors (unchanged) bg_color = "#2e2e2e" fg_color = "white" trough_color_red = "#4a0000" trough_color_green = "#004a00" trough_color_blue = "#00004a" trough_color_brightness = "#4a4a4a" trough_color_delay = "#4a4a4a" active_bg_color = "#4a4a4a" highlight_pattern_color = "#6a5acd" # New color for active color in palette active_palette_color_border = "#FFD700" # Gold color class App: def __init__(self) -> None: self.root = tk.Tk() # self.root.attributes("-fullscreen", True) self.root.configure(bg=bg_color) # --- WebSocketClient --- self.websocket_client = WebSocketClient("ws://192.168.4.1:80/ws") self.root.after(100, async_handler(self.websocket_client.connect)) # --- MIDI Configuration --- self.available_midi_ports = self.get_midi_ports() self.current_midi_port_index = self.load_midi_device_preference() # Load saved preference self.midi_handler: MidiHandler | None = None self.midi_task: asyncio.Task | None = None # Initialize MIDI handler with saved port (will be done after GUI is created) self.pending_midi_init = True if self.available_midi_ports else False # Configure ttk style (unchanged) style = ttk.Style() style.theme_use("alt") style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14)) style.configure("TNotebook", background=bg_color, borderwidth=0) style.configure( "TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5] ) # (Status box removed per request) # MIDI Controller Selection midi_frame = ttk.LabelFrame(self.root, text="MIDI Controller") midi_frame.pack(padx=16, pady=8, fill="x") # MIDI port dropdown self.midi_port_var = tk.StringVar() if self.available_midi_ports: # Set to saved preference if available, otherwise first port if 0 <= self.current_midi_port_index < len(self.available_midi_ports): self.midi_port_var.set(self.available_midi_ports[self.current_midi_port_index]) else: self.midi_port_var.set(self.available_midi_ports[0]) self.current_midi_port_index = 0 midi_dropdown = ttk.Combobox( midi_frame, textvariable=self.midi_port_var, values=self.available_midi_ports, state="readonly", font=("Arial", 12) ) midi_dropdown.pack(padx=8, pady=4, fill="x") midi_dropdown.bind("<>", self.on_midi_port_change) # Refresh MIDI ports button refresh_button = ttk.Button( midi_frame, text="Refresh MIDI Ports", command=self.refresh_midi_ports ) refresh_button.pack(padx=8, pady=4) # MIDI connection status self.midi_status_label = tk.Label( midi_frame, text="Status: Disconnected", bg=bg_color, fg="red", font=("Arial", 10) ) self.midi_status_label.pack(padx=8, pady=2) # Initialize MIDI handler now that GUI is ready if self.pending_midi_init: self.initialize_midi_handler() self.pending_midi_init = False # Controls overview (dials grid left, buttons grids right) controls_frame = ttk.Frame(self.root) controls_frame.pack(padx=16, pady=8, fill="both") # Dials box: 4 rows by 2 columns (top-left origin): # Row0: n3 (left), Delay (right) # Row1: n1 (left), n2 (right) # Row2: B (left), Brightness (right) # Row3: R (left), G (right) dials_frame = ttk.LabelFrame(controls_frame, text="Dials (CC30-37)") dials_frame.pack(side="left", padx=12) for c in range(2): dials_frame.grid_columnconfigure(c, minsize=140) for rr in range(4): dials_frame.grid_rowconfigure(rr, minsize=70) self.dials_boxes: list[tk.Label] = [] # Create with placeholders so they are visible before first update placeholders = { (0, 0): "n3\n-", (0, 1): "Delay\n-", (1, 0): "n1\n-", (1, 1): "n2\n-", (2, 0): "B\n-", (2, 1): "Bright\n-", (3, 0): "R\n-", (3, 1): "G\n-", } for r in range(4): for c in range(2): lbl = tk.Label( dials_frame, text=placeholders.get((r, c), "-"), bg=bg_color, fg=fg_color, font=("Arial", 14), padx=6, pady=6, borderwidth=2, relief="ridge", width=14, height=4, anchor="center", justify="center", ) lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew") self.dials_boxes.append(lbl) # Additional knobs box: 4 rows by 2 columns (CC38-45) # Row0: K1 (left), K2 (right) # Row1: K3 (left), K4 (right) # Row2: K5 (left), K6 (right) # Row3: K7 (left), K8 (right) knobs_frame = ttk.LabelFrame(controls_frame, text="Knobs (CC38-45)") knobs_frame.pack(side="left", padx=12) for c in range(2): knobs_frame.grid_columnconfigure(c, minsize=140) for rr in range(4): knobs_frame.grid_rowconfigure(rr, minsize=70) self.knobs_boxes: list[tk.Label] = [] # Create with placeholders so they are visible before first update knob_placeholders = { (0, 0): "CC44\n-", (0, 1): "CC45\n-", (1, 0): "Rad n1\n-", (1, 1): "Rad delay\n-", (2, 0): "Alt n1\n-", (2, 1): "Alt n2\n-", (3, 0): "Pulse n1\n-", (3, 1): "Pulse n2\n-", } for r in range(4): for c in range(2): lbl = tk.Label( knobs_frame, text=knob_placeholders.get((r, c), "-"), bg=bg_color, fg=fg_color, font=("Arial", 14), padx=6, pady=6, borderwidth=2, relief="ridge", width=14, height=4, anchor="center", justify="center", ) lbl.grid(row=r, column=c, padx=6, pady=6, sticky="nsew") self.knobs_boxes.append(lbl) # Buttons bank (single) buttons_frame = ttk.Frame(controls_frame) buttons_frame.pack(side="left", padx=12) buttons1_frame = ttk.LabelFrame(buttons_frame, text="Buttons (notes 36-51)") buttons1_frame.pack(side="top", pady=8) for c in range(4): buttons1_frame.grid_columnconfigure(c, minsize=140) for rr in range(1, 5): buttons1_frame.grid_rowconfigure(rr, minsize=70) self.button1_cells: list[tk.Label] = [] for r in range(4): for c in range(4): lbl = tk.Label( buttons1_frame, text="", bg=bg_color, fg=fg_color, font=("Arial", 14), padx=6, pady=6, borderwidth=2, relief="ridge", width=14, height=4, anchor="center", justify="center", ) lbl.grid(row=1 + (3 - r), column=c, padx=6, pady=6, sticky="nsew") self.button1_cells.append(lbl) # (No second buttons bank) # (No status labels to pack) # schedule periodic UI updates self.root.after(200, self.update_status_labels) self.root.protocol("WM_DELETE_WINDOW", self.on_closing) async_mainloop(self.root) def load_midi_device_preference(self): """Load saved MIDI device preference from config file""" try: if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, 'r') as f: config = json.load(f) saved_index = config.get('midi_device_index', 0) print(f"Loaded MIDI device preference: index {saved_index}") return saved_index except Exception as e: print(f"Error loading MIDI device preference: {e}") return 0 # Default to first port def save_midi_device_preference(self): """Save current MIDI device preference to config file""" try: config = { 'midi_device_index': self.current_midi_port_index, 'midi_device_name': self.available_midi_ports[self.current_midi_port_index] if self.available_midi_ports else None } with open(CONFIG_FILE, 'w') as f: json.dump(config, f, indent=2) print(f"Saved MIDI device preference: {config['midi_device_name']} (index {config['midi_device_index']})") except Exception as e: print(f"Error saving MIDI device preference: {e}") def get_midi_ports(self): """Get list of available MIDI input ports""" try: port_names = mido.get_input_names() return port_names except Exception as e: print(f"Error getting MIDI ports: {e}") return [] def initialize_midi_handler(self): """Initialize MIDI handler with current port""" if not self.available_midi_ports: print("No MIDI ports available") return try: WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws" print(f"Initializing MIDI handler with port index {self.current_midi_port_index}") self.midi_handler = MidiHandler(self.current_midi_port_index, WEBSOCKET_SERVER_URI) print("MIDI handler initialized") # Update status port_name = self.available_midi_ports[self.current_midi_port_index] if hasattr(self, 'midi_status_label'): self.midi_status_label.config(text=f"Status: Connected to {port_name}", fg="green") # Start MIDI in background self.root.after(0, async_handler(self.start_midi)) except Exception as e: print(f"Error initializing MIDI handler: {e}") messagebox.showerror("MIDI Error", f"Failed to initialize MIDI handler:\n{e}") self.midi_handler = None if hasattr(self, 'midi_status_label'): self.midi_status_label.config(text="Status: Error", fg="red") def refresh_midi_ports(self): """Refresh the list of available MIDI ports""" old_ports = self.available_midi_ports.copy() self.available_midi_ports = self.get_midi_ports() # Update dropdown values midi_dropdown = None for child in self.root.winfo_children(): if isinstance(child, ttk.LabelFrame) and child.cget("text") == "MIDI Controller": for widget in child.winfo_children(): if isinstance(widget, ttk.Combobox): midi_dropdown = widget break break if midi_dropdown: midi_dropdown['values'] = self.available_midi_ports if self.available_midi_ports and self.midi_port_var.get() not in self.available_midi_ports: # Current selection is no longer available, select first available self.midi_port_var.set(self.available_midi_ports[0]) self.current_midi_port_index = 0 self.save_midi_device_preference() # Save the new preference self.restart_midi_handler() elif not self.available_midi_ports: # No ports available self.midi_port_var.set("No MIDI ports found") if hasattr(self, 'midi_status_label'): self.midi_status_label.config(text="Status: No MIDI ports available", fg="orange") # Stop current MIDI handler if running if self.midi_task and not self.midi_task.done(): self.midi_task.cancel() self.midi_handler = None print(f"MIDI ports refreshed. Available: {self.available_midi_ports}") def on_midi_port_change(self, event): """Handle MIDI port selection change""" selected_port = self.midi_port_var.get() if selected_port in self.available_midi_ports: self.current_midi_port_index = self.available_midi_ports.index(selected_port) print(f"MIDI port changed to: {selected_port} (index: {self.current_midi_port_index})") self.save_midi_device_preference() # Save the new preference self.restart_midi_handler() @async_handler async def restart_midi_handler(self): """Restart MIDI handler with new port""" try: # Stop current MIDI task if self.midi_task and not self.midi_task.done(): self.midi_task.cancel() try: await self.midi_task except asyncio.CancelledError: pass # Initialize new MIDI handler self.initialize_midi_handler() except Exception as e: print(f"Error restarting MIDI handler: {e}") messagebox.showerror("MIDI Error", f"Failed to restart MIDI handler:\n{e}") @async_handler async def start_midi(self): # Launch MidiHandler.run() as a background task if self.midi_handler and (self.midi_task is None or self.midi_task.done()): self.midi_task = asyncio.create_task(self.midi_handler.run()) elif not self.midi_handler: print("Cannot start MIDI: no MIDI handler available") def on_closing(self): print("Closing application...") if self.midi_task and not self.midi_task.done(): self.midi_task.cancel() asyncio.create_task(self.websocket_client.close()) self.root.destroy() # --- Asynchronous Update Functions --- @async_handler async def update_rgb(self, tab): await asyncio.sleep(0) # Yield control def update_status_labels(self): # Check if MIDI handler is available if not self.midi_handler: # Update dial displays with placeholder values placeholders = [ ("n3", "-"), ("Delay", "-"), ("n1", "-"), ("n2", "-"), ("B", "-"), ("Brightness", "-"), ("R", "-"), ("G", "-"), ] for idx, (label, value) in enumerate(placeholders): if idx < len(self.dials_boxes): self.dials_boxes[idx].config(text=f"{label}\n{value}") # Update knobs with placeholder values knob_placeholders = [ ("CC44", "-"), ("CC45", "-"), ("Rad n1", "-"), ("Rad delay", "-"), ("Alt n1", "-"), ("Alt n2", "-"), ("Pulse n1", "-"), ("Pulse n2", "-"), ] for idx, (label, value) in enumerate(knob_placeholders): if idx < len(self.knobs_boxes): self.knobs_boxes[idx].config(text=f"{label}\n{value}") # Update buttons with no selection for lbl in self.button1_cells: lbl.config(text="", bg=bg_color) # Reschedule self.root.after(200, self.update_status_labels) return # Pull values from midi_handler delay = self.midi_handler.delay brightness = self.midi_handler.brightness r = getattr(self.midi_handler, 'color_r', 0) g = getattr(self.midi_handler, 'color_g', 0) b = getattr(self.midi_handler, 'color_b', 0) # Single bank values brightness = getattr(self.midi_handler, 'brightness', '-') r = getattr(self.midi_handler, 'color_r', 0) g = getattr(self.midi_handler, 'color_g', 0) b = getattr(self.midi_handler, 'color_b', 0) pattern = getattr(self.midi_handler, 'current_pattern', '') or '-' n1 = getattr(self.midi_handler, 'n1', '-') n2 = getattr(self.midi_handler, 'n2', '-') n3 = getattr(self.midi_handler, 'n3', '-') # Update dials 2x4 grid (left→right, top→bottom): # Row0: n3, Delay # Row1: n1, n2 # Row2: B, Brightness # Row3: R, G dial_values = [ ("n3", n3), ("Delay", getattr(self.midi_handler, 'delay', '-')), ("n1", n1), ("n2", n2), ("B", b), ("Brightness", brightness), ("R", r), ("G", g), ] # Update dial displays for idx, (label, value) in enumerate(dial_values): if idx < len(self.dials_boxes): self.dials_boxes[idx].config(text=f"{label}\n{value}") # Update additional knobs (CC38-45) knob_values = [ ("CC44", getattr(self.midi_handler, 'knob7', '-')), ("CC45", getattr(self.midi_handler, 'knob8', '-')), ("Rad n1", getattr(self.midi_handler, 'n1', '-')), ("Rad delay", getattr(self.midi_handler, 'delay', '-')), ("Alt n1", getattr(self.midi_handler, 'n1', '-')), ("Alt n2", getattr(self.midi_handler, 'n2', '-')), ("Pulse n1", getattr(self.midi_handler, 'n1', '-')), ("Pulse n2", getattr(self.midi_handler, 'n2', '-')), ] for idx, (label, value) in enumerate(knob_values): if idx < len(self.knobs_boxes): self.knobs_boxes[idx].config(text=f"{label}\n{value}") # Update buttons bank mappings and selection (single bank) # Pattern icons for nicer appearance icon_for = { "pulse": "💥", "flicker": "✨", "alternating": "↔️", "n chase": "🏃", "rainbow": "🌈", "radiate": "🌟", "sequential\npulse": "🔄", "alternating\nphase": "⚡", "-": "", } bank1_patterns = [ # Pulse patterns (row 1) "pulse", "sequential\npulse", # Alternating patterns (row 2) "alternating", "alternating\nphase", # Chase/movement patterns (row 3) "n chase", "rainbow", # Effect patterns (row 4) "flicker", "radiate", "-", "-", "-", "-", "-", "-", "-", "-", ] # Map MIDI handler pattern names to GUI display names pattern_name_mapping = { "sequential_pulse": "sequential\npulse", "alternating_phase": "alternating\nphase", "n_chase": "n chase", } # Get the display name for the current pattern display_pattern = pattern_name_mapping.get(pattern, pattern) # notes numbers per cell (bottom-left origin) for idx, lbl in enumerate(self.button1_cells): name = bank1_patterns[idx] sel = (display_pattern == name and name != "-") icon = icon_for.get(name, "") text = f"{icon} {name}" if name != "-" else "" if sel: lbl.config(text=text, bg=highlight_pattern_color) else: lbl.config(text=text, bg=bg_color) # (no second bank to update) # reschedule self.root.after(200, self.update_status_labels) if __name__ == "__main__": app = App()