#!/usr/bin/env python3 """ UI Client for Lighting Controller Handles the user interface and MIDI controller input. Communicates with the control server via WebSocket. """ import asyncio import tkinter as tk from tkinter import ttk, messagebox import json import os import mido import logging from async_tkinter_loop import async_handler, async_mainloop # WebSocket removed; using REST API only import atexit import signal import urllib.request import urllib.error # Single instance locker to prevent multiple UI processes class SingleInstanceLocker: def __init__(self, name: str): self.name = name self.lock_path = f"/tmp/{name}.lock" self._fd = None self.acquire() def acquire(self): try: self._fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR) os.write(self._fd, str(os.getpid()).encode()) except FileExistsError: # If lock exists but process is dead, remove it try: with open(self.lock_path, 'r') as f: pid_str = f.read().strip() if pid_str and pid_str.isdigit(): pid = int(pid_str) try: os.kill(pid, 0) # Process exists, deny new instance raise SystemExit("Another UI instance is already running.") except OSError: # Stale lock; remove os.remove(self.lock_path) return self.acquire() else: os.remove(self.lock_path) return self.acquire() except Exception: raise SystemExit("Another UI instance may be running (lock busy).") def release(self): try: if self._fd is not None: os.close(self._fd) self._fd = None if os.path.exists(self.lock_path): os.remove(self.lock_path) except Exception: pass # Configuration CONFIG_FILE = "config.json" # Minimal .env loader (no external dependency) def load_dotenv(filepath: str = ".env"): try: if not os.path.exists(filepath): return with open(filepath, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue if '=' not in line: continue key, value = line.split('=', 1) key = key.strip() value = value.strip().strip('"').strip("'") # Do not overwrite if already set in environment if key and key not in os.environ: os.environ[key] = value except Exception: # Silently ignore .env parse errors to avoid breaking UI pass # Load environment variables from .env if present load_dotenv() # Control server URI can be overridden via environment CONTROL_SERVER_URI = os.getenv("CONTROL_SERVER_URI", "ws://10.1.1.117:8765") def _build_palette_api_base() -> str: try: # Expect ws://host:port or ws://host uri = CONTROL_SERVER_URI if uri.startswith("ws://"): http = "http://" + uri[len("ws://"):] elif uri.startswith("wss://"): http = "https://" + uri[len("wss://"):] else: http = uri # Append API path if http.endswith('/'): http = http[:-1] return f"{http}/api/color-palette" except Exception: return "http://localhost:8765/api/color-palette" PALETTE_API_BASE = _build_palette_api_base() def _build_base_http() -> str: try: uri = CONTROL_SERVER_URI if uri.startswith("ws://"): http = "http://" + uri[len("ws://"):] elif uri.startswith("wss://"): http = "https://" + uri[len("wss://"):] else: http = uri if http.endswith('/'): http = http[:-1] return http except Exception: return "http://localhost:8765" HTTP_BASE = _build_base_http() # Dark theme colors 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" active_palette_color_border = "#FFD700" # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # WebSocket client removed class MidiController: """Handles MIDI controller input and sends commands to control server.""" def __init__(self, websocket_client): self.websocket_client = websocket_client self.midi_port_index = 0 self.available_ports = [] self.midi_port = None self.midi_task = None # MIDI state self.current_pattern = "" self.delay = 100 self.brightness = 100 self.color_r = 0 self.color_g = 255 self.color_b = 0 self.n1 = 10 self.n2 = 10 self.n3 = 1 self.knob7 = 0 self.knob8 = 0 self.beat_sending_enabled = True # Color palette selection state (two selected indices, alternating target) self.selected_indices = [0, 1] self.next_selected_target = 0 # 0 selects color1 next, 1 selects color2 next # Optional async callback set by UI to persist selected indices via REST self.on_select_palette_indices = None # Optional async callback to persist parameter changes via REST self.on_parameters_change = None def get_midi_ports(self): """Get list of available MIDI input ports.""" try: return mido.get_input_names() except Exception as e: logging.error(f"Error getting MIDI ports: {e}") return [] def load_midi_preference(self): """Load saved MIDI device preference.""" try: if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, 'r') as f: config = json.load(f) return config.get('midi_device_index', 0) except Exception as e: logging.error(f"Error loading MIDI preference: {e}") return 0 def save_midi_preference(self): """Save current MIDI device preference.""" try: config = { 'midi_device_index': self.midi_port_index, 'midi_device_name': self.available_ports[self.midi_port_index] if self.available_ports else None } with open(CONFIG_FILE, 'w') as f: json.dump(config, f, indent=2) except Exception as e: logging.error(f"Error saving MIDI preference: {e}") async def initialize_midi(self): """Initialize MIDI port connection.""" self.available_ports = self.get_midi_ports() self.midi_port_index = self.load_midi_preference() if not self.available_ports: logging.warning("No MIDI ports available") return False if not (0 <= self.midi_port_index < len(self.available_ports)): self.midi_port_index = 0 try: port_name = self.available_ports[self.midi_port_index] self.midi_port = mido.open_input(port_name) logging.info(f"Connected to MIDI port: {port_name}") return True except Exception as e: logging.error(f"Failed to open MIDI port: {e}") return False async def start_midi_listener(self): """Start listening for MIDI messages.""" if not self.midi_port: return try: while True: msg = self.midi_port.receive(block=False) if msg: await self.handle_midi_message(msg) await asyncio.sleep(0.001) except asyncio.CancelledError: logging.info("MIDI listener cancelled") except Exception as e: logging.error(f"MIDI listener error: {e}") async def handle_midi_message(self, msg): """Handle incoming MIDI message and send to control server.""" if msg.type == 'note_on': # Pattern selection for specific MIDI notes logging.info(f"MIDI Note {msg.note}: {msg.velocity}") note_to_pattern = { 36: "alternating_phase", 37: "o", 38: "f", 39: "ap", 40: "p", 41: "r", 42: "rd", 43: "sm", } pattern = note_to_pattern.get(msg.note) if pattern: self.current_pattern = pattern # Send pattern change via REST per api.md try: data = json.dumps({"pattern": pattern}).encode('utf-8') req = urllib.request.Request(f"{HTTP_BASE}/api/pattern", data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: _ = resp.read() logging.info(f"Pattern changed to: {pattern}") except Exception as e: logging.error(f"Failed to POST pattern change: {e}") return # Color selection notes 44-51 map to color slots and targets if 44 <= msg.note <= 51: slot_index = msg.note - 44 # Notes 44-47 → Color 2 (target index 1), 48-51 → Color 1 (target index 0) target = 1 if 44 <= msg.note <= 47 else 0 self.selected_indices[target] = slot_index # Update indicator to reflect last target used self.next_selected_target = target if callable(self.on_select_palette_indices): try: self.on_select_palette_indices(self.selected_indices) except Exception as _e: logging.debug(f"Failed to persist selected indices: {_e}") logging.info(f"Set Color {target+1} to slot {slot_index+1}") elif msg.type == 'control_change': # Handle control change messages control = msg.control value = msg.value logging.info(f"MIDI CC {control}: {value}") if control == 33: # Brightness (0-100) self.brightness = round((value / 127) * 100) if callable(self.on_parameters_change): self.on_parameters_change({"brightness": self.brightness}) elif control == 34: # n1 (0-255) self.n1 = int(value) if callable(self.on_parameters_change): self.on_parameters_change({"n1": self.n1}) elif control == 35: # n2 (0-255) self.n2 = int(value) if callable(self.on_parameters_change): self.on_parameters_change({"n2": self.n2}) elif control == 36: # n3 (>=1) self.n3 = max(1, int(value)) if callable(self.on_parameters_change): self.on_parameters_change({"n3": self.n3}) elif control == 37: # Delay (ms) self.delay = int(value) * 4 if callable(self.on_parameters_change): self.on_parameters_change({"delay": self.delay}) elif control == 27: # Beat sending toggle (not used in REST; reserved) self.beat_sending_enabled = (value == 127) def close(self): """Close MIDI connection.""" if self.midi_port: self.midi_port.close() self.midi_port = None class UIClient: """Main UI client application.""" def __init__(self): # Ensure single instance via lock file self._lock = SingleInstanceLocker('lighting_controller_ui') self.root = tk.Tk() self.root.configure(bg=bg_color) self.root.title("Lighting Controller - UI Client") # Restore last window geometry if available self.load_window_geometry() # MIDI controller self.midi_controller = MidiController(None) # UI state self.current_pattern = "" self.delay = 100 self.brightness = 100 self.color_r = 0 self.color_g = 255 self.color_b = 0 self.n1 = 10 self.n2 = 10 self.n3 = 1 self.n4 = 1 # Cache for per-pattern windows self.pattern_windows = {} # Per-pattern parameters storage: n1..n4, delay are unique per pattern self.pattern_params = {} # Color slots (8) and selected slot via MIDI self.color_slots = [] self.selected_color_slot = None self._init_color_slots() self.setup_ui() self.setup_async_tasks() # Hook MIDI controller selection persistence to REST method try: self.midi_controller.on_select_palette_indices = self.persist_selected_indices self.midi_controller.on_parameters_change = self.persist_parameters except Exception: pass # Graceful shutdown on signals try: signal.signal(signal.SIGTERM, lambda *_: self.on_closing()) signal.signal(signal.SIGINT, lambda *_: self.on_closing()) except Exception: pass atexit.register(self._cleanup_at_exit) def setup_ui(self): """Setup the user interface.""" # Configure ttk style 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]) # Top bar: MIDI Controller (left) + Selected Colors (right) top_bar = ttk.Frame(self.root) top_bar.pack(padx=16, pady=8, fill="x") # MIDI Controller Selection (smaller, on the left) midi_frame = ttk.LabelFrame(top_bar, text="MIDI Controller") midi_frame.pack(side="left", padx=8) # MIDI port dropdown self.midi_port_var = tk.StringVar() midi_dropdown = ttk.Combobox( midi_frame, textvariable=self.midi_port_var, values=[], state="readonly", font=("Arial", 11) ) 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) # Selected color preview boxes (on the right) previews_frame = ttk.LabelFrame(top_bar, text="Selected Colors") previews_frame.pack(side="right", padx=8) self.color1_preview = tk.Label(previews_frame, text="Color 1", width=14, height=2, bg="#000000", fg="#FFFFFF", font=("Arial", 12), borderwidth=2, relief="ridge") self.color2_preview = tk.Label(previews_frame, text="Color 2", width=14, height=2, bg="#000000", fg="#FFFFFF", font=("Arial", 12), borderwidth=2, relief="ridge") self.color1_preview.grid(row=0, column=0, padx=8, pady=8) self.color2_preview.grid(row=1, column=0, padx=8, pady=8) # Click to choose which target is set by MIDI def _set_target_color1(_e=None): try: self.midi_controller.next_selected_target = 0 except Exception: pass def _set_target_color2(_e=None): try: self.midi_controller.next_selected_target = 1 except Exception: pass self.color1_preview.bind("", _set_target_color1) self.color2_preview.bind("", _set_target_color2) # Controls overview controls_frame = ttk.Frame(self.root) controls_frame.pack(padx=16, pady=8, fill="both") # Dials display 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 = [] 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) # Knobs display 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 = [] 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 display 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 = [] 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) # Bind clicks to open/focus child window and select pattern for idx, lbl in enumerate(self.button1_cells): lbl.bind("", lambda e, i=idx: self.on_pattern_button_click(i)) # (Previews moved to top bar) # Connection status self.connection_status = tk.Label( self.root, text="Control Server: Disconnected", bg=bg_color, fg="red", font=("Arial", 12) ) self.connection_status.pack(pady=8) # Schedule periodic UI updates self.root.after(200, self.update_status_labels) self.root.protocol("WM_DELETE_WINDOW", self.on_closing) def open_pattern_window(self, pattern_name: str): """Create or focus a child window per pattern with sliders for parameters.""" if pattern_name in self.pattern_windows: win = self.pattern_windows[pattern_name] if win.winfo_exists(): try: win.deiconify() win.lift() win.focus_force() except Exception: pass return win = tk.Toplevel(self.root) win.title(f"Pattern: {pattern_name}") win.configure(bg=bg_color) # Make the child window larger for touch use try: win.geometry("900x520") except Exception: pass self.pattern_windows[pattern_name] = win def on_close(): try: win.withdraw() except Exception: try: win.destroy() except Exception: pass win.protocol("WM_DELETE_WINDOW", on_close) frm = ttk.Frame(win) frm.pack(padx=20, pady=20, fill="both", expand=True) # Helper to add a labeled slider def add_slider(row: int, label_text: str, from_value, to_value, initial_value, on_change_cb): lbl = tk.Label(frm, text=label_text, bg=bg_color, fg=fg_color, font=("Arial", 16)) lbl.grid(row=row, column=0, sticky="w", padx=12, pady=16) var = tk.IntVar(value=int(initial_value)) s = tk.Scale( frm, from_=from_value, to=to_value, orient="horizontal", showvalue=True, resolution=1, variable=var, length=700, sliderlength=32, width=26, bg=bg_color, fg=fg_color, highlightthickness=0, troughcolor=trough_color_brightness, command=lambda v: on_change_cb(int(float(v))), ) # Enable click-to-jump behavior on the slider trough def click_set_value(event, scale=s, vmin=from_value, vmax=to_value): try: # Compute fraction across the widget width width = max(1, scale.winfo_width()) frac = min(1.0, max(0.0, event.x / width)) value = int(round(vmin + frac * (vmax - vmin))) scale.set(value) on_change_cb(int(value)) except Exception: pass s.bind("", click_set_value, add="+") s.grid(row=row, column=1, sticky="ew", padx=12, pady=16) frm.grid_columnconfigure(1, weight=1) return s # Fetch existing params for this pattern or defaults pvals = self.get_params_for(pattern_name) # Sliders: n1, n2, n3, optional n4 for segmented_movement, then delay add_slider(0, "n1", 0, 255, pvals.get("n1", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n1", v)) add_slider(1, "n2", 0, 255, pvals.get("n2", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n2", v)) add_slider(2, "n3", 0, 255, pvals.get("n3", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n3", v)) next_row = 3 if pattern_name == "segmented_movement": add_slider(next_row, "n4", 0, 255, pvals.get("n4", 0), lambda v, pn=pattern_name: self._on_change_param(pn, "n4", v)) next_row += 1 add_slider(next_row, "delay (ms)", 1, 1000, pvals.get("delay", 100), lambda v, pn=pattern_name: self._on_change_param(pn, "delay", v)) # Close button row btn_row = 4 btns = ttk.Frame(win) btns.pack(fill="x", padx=16, pady=10) close_btn = tk.Button(btns, text="Close", command=on_close, font=("Arial", 14), padx=16, pady=8) close_btn.pack(side="right") try: win.deiconify(); win.lift() except Exception: pass @async_handler @async_handler async def _on_change_param(self, pattern_name: str, key: str, value: int): val = int(value) # Store per-pattern self.set_param(pattern_name, key, val) # If editing current pattern, persist immediately if pattern_name == self.current_pattern: self.persist_parameters({key: val}) def get_params_for(self, pattern_name: str) -> dict: if pattern_name not in self.pattern_params: self.pattern_params[pattern_name] = {"n1": 0, "n2": 0, "n3": 0, "n4": 0, "delay": 100} return self.pattern_params[pattern_name] def set_param(self, pattern_name: str, key: str, value: int): entry = self.get_params_for(pattern_name) entry[key] = int(value) def setup_async_tasks(self): """Setup async tasks for WebSocket and MIDI.""" # Load full system state on startup (pattern, parameters, palette) self.root.after(100, async_handler(self.fetch_full_state)) # Initialize MIDI self.root.after(200, async_handler(self.initialize_midi)) @async_handler async def initialize_midi(self): """Initialize MIDI controller.""" success = await self.midi_controller.initialize_midi() if success: # Update UI self.midi_controller.available_ports = self.midi_controller.get_midi_ports() if self.midi_controller.available_ports: self.midi_port_var.set(self.midi_controller.available_ports[self.midi_controller.midi_port_index]) # Update dropdown 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): widget['values'] = self.midi_controller.available_ports break break self.midi_status_label.config( text=f"Status: Connected to {self.midi_controller.available_ports[self.midi_controller.midi_port_index]}", fg="green" ) # Start MIDI listener self.midi_controller.midi_task = asyncio.create_task( self.midi_controller.start_midi_listener() ) # WebSocket reconnect removed def refresh_midi_ports(self): """Refresh MIDI ports list.""" old_ports = self.midi_controller.available_ports.copy() self.midi_controller.available_ports = self.midi_controller.get_midi_ports() # Update dropdown 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): widget['values'] = self.midi_controller.available_ports if (self.midi_controller.available_ports and self.midi_port_var.get() not in self.midi_controller.available_ports): self.midi_port_var.set(self.midi_controller.available_ports[0]) self.midi_controller.midi_port_index = 0 self.midi_controller.save_midi_preference() break break def on_midi_port_change(self, event): """Handle MIDI port selection change.""" selected_port = self.midi_port_var.get() if selected_port in self.midi_controller.available_ports: self.midi_controller.midi_port_index = self.midi_controller.available_ports.index(selected_port) self.midi_controller.save_midi_preference() # Restart MIDI connection self.restart_midi() @async_handler async def restart_midi(self): """Restart MIDI connection with new port.""" if self.midi_controller.midi_task: self.midi_controller.midi_task.cancel() if self.midi_controller.midi_port: self.midi_controller.midi_port.close() success = await self.midi_controller.initialize_midi() if success: self.midi_controller.midi_task = asyncio.create_task( self.midi_controller.start_midi_listener() ) def update_status_labels(self): """Update UI status labels.""" # Update connection status # REST-only: show base URL self.connection_status.config(text=f"API: {HTTP_BASE}", fg="green") # Update dial displays dial_values = [ ("n3", self.midi_controller.n3), ("Delay", self.midi_controller.delay), ("n1", self.midi_controller.n1), ("n2", self.midi_controller.n2), ("B", self.midi_controller.color_b), ("Brightness", self.midi_controller.brightness), ("R", self.midi_controller.color_r), ("G", self.midi_controller.color_g), ] 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 knobs knob_values = [ ("CC44", self.midi_controller.knob7), ("CC45", self.midi_controller.knob8), ("Rad n1", self.midi_controller.n1), ("Rad delay", self.midi_controller.delay), ("Alt n1", self.midi_controller.n1), ("Alt n2", self.midi_controller.n2), ("Pulse n1", self.midi_controller.n1), ("Pulse n2", self.midi_controller.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 icon_for = { # long names "alternating_phase": "↔️", "off": "⚫", "flicker": "✨", "alternating": "↔️", "pulse": "💥", "rainbow": "🌈", "radiate": "🌟", "segmented_movement": "🔀", # short codes used by led-bar "o": "⚫", "f": "✨", "a": "↔️", "p": "💥", "r": "🌈", "rd": "🌟", "sm": "🔀", "-": "", } bank1_patterns = self.get_bank1_patterns() # Display names for UI (with line breaks for better display) display_names = { # long "alternating_phase": "alternating\nphase", "off": "off", "flicker": "flicker", "alternating": "alternating\npulse", "pulse": "pulse", "rainbow": "rainbow", "radiate": "radiate", "segmented_movement": "segmented\nmovement", # short "o": "off", "f": "flicker", "a": "alternating", "p": "pulse", "r": "rainbow", "rd": "radiate", "sm": "segmented\nmovement", } # Normalize current pattern for highlight (map short codes to long names) current_raw = self.midi_controller.current_pattern or self.current_pattern short_to_long = { "o": "off", "f": "flicker", "a": "alternating", "ap": "alternating", "p": "pulse", "r": "rainbow", "rd": "radiate", "sm": "segmented_movement", } current_pattern = short_to_long.get(current_raw, current_raw) for idx, lbl in enumerate(self.button1_cells): pattern_name = bank1_patterns[idx] is_selected = (current_pattern == pattern_name and pattern_name != "-") display_name = display_names.get(pattern_name, pattern_name) icon = icon_for.get(pattern_name, icon_for.get(current_raw, "")) text = f"{icon} {display_name}" if pattern_name != "-" else "" if is_selected: lbl.config(text=text, bg=highlight_pattern_color) else: lbl.config(text=text, bg=bg_color) # Render color cells in indices 8..15 if self.color_slots and len(self.button1_cells) >= 16: for color_idx in range(8): cell_index = 8 + color_idx lbl = self.button1_cells[cell_index] r, g, b = self.color_slots[color_idx] hex_color = self._rgb_to_hex(r, g, b) text_color = "#000000" if (r*0.299 + g*0.587 + b*0.114) > 186 else "#FFFFFF" # Indicate if this slot is currently assigned to color1 or color2 assigned = "1" if (hasattr(self.midi_controller, 'selected_indices') and self.midi_controller.selected_indices and self.midi_controller.selected_indices[0] == color_idx) else ("2" if (hasattr(self.midi_controller, 'selected_indices') and len(self.midi_controller.selected_indices) > 1 and self.midi_controller.selected_indices[1] == color_idx) else "") label_text = f"C{color_idx+1}{' ('+assigned+')' if assigned else ''}" lbl.config( text=label_text, bg=hex_color, fg=text_color, ) # Update selected color preview boxes try: if hasattr(self, 'color1_preview') and hasattr(self, 'color2_preview'): if hasattr(self.midi_controller, 'selected_indices') and self.color_slots: idx1 = self.midi_controller.selected_indices[0] if len(self.midi_controller.selected_indices) > 0 else 0 idx2 = self.midi_controller.selected_indices[1] if len(self.midi_controller.selected_indices) > 1 else 1 r1, g1, b1 = self.color_slots[idx1] r2, g2, b2 = self.color_slots[idx2] # Update preview colors self.color1_preview.configure(bg=self._rgb_to_hex(r1, g1, b1), fg=("#000000" if (r1*0.299+g1*0.587+b1*0.114) > 186 else "#FFFFFF")) self.color2_preview.configure(bg=self._rgb_to_hex(r2, g2, b2), fg=("#000000" if (r2*0.299+g2*0.587+b2*0.114) > 186 else "#FFFFFF")) # Indicate which color will be set next by MIDI (toggle target) next_target = getattr(self.midi_controller, 'next_selected_target', 0) if next_target == 0: # Next sets Color 1 self.color1_preview.configure(text="Color 1 (next)", borderwidth=3, relief="solid") self.color2_preview.configure(text="Color 2", borderwidth=2, relief="ridge") else: # Next sets Color 2 self.color1_preview.configure(text="Color 1", borderwidth=2, relief="ridge") self.color2_preview.configure(text="Color 2 (next)", borderwidth=3, relief="solid") except Exception: pass # Render color cells in indices 8..15 if self.color_slots and len(self.button1_cells) >= 16: for color_idx in range(8): cell_index = 8 + color_idx lbl = self.button1_cells[cell_index] r, g, b = self.color_slots[color_idx] hex_color = self._rgb_to_hex(r, g, b) text_color = "#000000" if (r*0.299 + g*0.587 + b*0.114) > 186 else "#FFFFFF" is_selected_color = (self.selected_color_slot == color_idx) lbl.config( text=f"C{color_idx+1}", bg=hex_color, fg=text_color, borderwidth=4 if is_selected_color else 2, relief="solid" if is_selected_color else "ridge", ) # Reschedule self.root.after(200, self.update_status_labels) @async_handler async def fetch_full_state(self): """GET /api/state to hydrate current pattern, parameters, and palette.""" try: req = urllib.request.Request(f"{HTTP_BASE}/api/state", method='GET') with urllib.request.urlopen(req, timeout=2.0) as resp: data = json.loads(resp.read().decode('utf-8')) # Pattern pat = data.get("pattern") if isinstance(pat, str) and pat: self.current_pattern = pat # Parameters (for current pattern) params = data.get("parameters") or {} if isinstance(params, dict) and self.current_pattern: # Normalize ints norm = {} for k in ("n1", "n2", "n3", "n4", "delay", "brightness"): if k in params: try: norm[k] = int(params[k]) except Exception: pass # Store per-pattern params pstore = self.get_params_for(self.current_pattern) pstore.update({k: v for k, v in norm.items() if k in ("n1", "n2", "n3", "n4", "delay")}) # Update brightness display value if "brightness" in norm: self.midi_controller.brightness = norm["brightness"] # Color palette cp = data.get("color_palette") or {} pal = cp.get("palette") sel = cp.get("selected_indices") if isinstance(pal, list) and len(pal) == 8: self.color_slots = [ (int(max(0, min(255, (c or {}).get("r", 0)))), int(max(0, min(255, (c or {}).get("g", 0)))), int(max(0, min(255, (c or {}).get("b", 0))))) for c in pal ] if isinstance(sel, list) and len(sel) == 2: try: self.midi_controller.selected_indices = [int(sel[0]), int(sel[1])] except Exception: pass except Exception as e: logging.debug(f"Failed to fetch state (REST): {e}") finally: # Also refresh palette via dedicated call in case API version differs self.fetch_color_palette() @async_handler async def fetch_color_palette(self): """Request color palette from server and hydrate UI state.""" try: req = urllib.request.Request(PALETTE_API_BASE, method='GET') with urllib.request.urlopen(req, timeout=2.0) as resp: data = json.loads(resp.read().decode('utf-8')) palette = data.get("palette") selected_indices = data.get("selected_indices") if isinstance(palette, list) and len(palette) == 8: new_slots = [] for c in palette: r = int(c.get("r", 0)); g = int(c.get("g", 0)); b = int(c.get("b", 0)) r = max(0, min(255, r)); g = max(0, min(255, g)); b = max(0, min(255, b)) new_slots.append((r, g, b)) self.color_slots = new_slots if isinstance(selected_indices, list) and len(selected_indices) == 2: try: self.midi_controller.selected_indices = [int(selected_indices[0]), int(selected_indices[1])] except Exception: pass except Exception as e: logging.debug(f"Failed to fetch color palette (REST): {e}") @async_handler async def persist_palette(self): """POST current palette to server via REST.""" try: payload = { "palette": [{"r": c[0], "g": c[1], "b": c[2]} for c in self.color_slots] } data = json.dumps(payload).encode('utf-8') req = urllib.request.Request(PALETTE_API_BASE, data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: _ = resp.read() except Exception as e: logging.debug(f"Failed to persist palette (REST): {e}") @async_handler async def persist_selected_indices(self, indices: list[int]): """POST selected indices via REST.""" try: payload = {"selected_indices": [int(indices[0]), int(indices[1])]} if len(indices) == 2 else None if not payload: return data = json.dumps(payload).encode('utf-8') req = urllib.request.Request(PALETTE_API_BASE, data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: _ = resp.read() except Exception as e: logging.debug(f"Failed to persist selected indices (REST): {e}") @async_handler async def persist_parameters(self, params: dict): """POST parameter changes via REST to /api/parameters.""" try: data = json.dumps(params).encode('utf-8') req = urllib.request.Request(f"{HTTP_BASE}/api/parameters", data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: _ = resp.read() except Exception as e: logging.debug(f"Failed to persist parameters (REST): {e}") def on_closing(self): """Handle application closing.""" logging.info("Closing UI client...") # Persist window geometry self.save_window_geometry() if self.midi_controller.midi_task: self.midi_controller.midi_task.cancel() self.midi_controller.close() try: if self.websocket_client and self.websocket_client.reconnect_task: self.websocket_client._stop_reconnect = True self.websocket_client.reconnect_task.cancel() except Exception: pass asyncio.create_task(self.websocket_client.close()) self.root.destroy() # Release lock try: self._lock.release() except Exception: pass def run(self): """Run the UI client.""" async_mainloop(self.root) def _cleanup_at_exit(self): try: self._lock.release() except Exception: pass def get_bank1_patterns(self): return [ "alternating_phase", "off", "flicker", "alternating", "pulse", "rainbow", "radiate", "segmented_movement", "-", "-", "-", "-", "-", "-", "-", "-", ] def on_pattern_button_click(self, index: int): patterns = self.get_bank1_patterns() if 0 <= index < len(patterns): pattern = patterns[index] if index < 8 and pattern != "-": # Send selection and open the window self.select_pattern(pattern) self.open_pattern_window(pattern) elif index >= 8: # Open color editor window for the slot (no selection change) slot_index = index - 8 self.open_color_window(slot_index) @async_handler async def select_pattern(self, pattern: str): try: self.current_pattern = pattern # Use REST API per api.md try: data = json.dumps({"pattern": pattern}).encode('utf-8') req = urllib.request.Request(f"{HTTP_BASE}/api/pattern", data=data, method='POST', headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=2.0) as resp: res = json.loads(resp.read().decode('utf-8')) # If server returns parameters for this pattern, store and reflect them if isinstance(res, dict): params = res.get("parameters") or {} if isinstance(params, dict): # Normalize and store per-pattern pstore = self.get_params_for(pattern) for k in ("n1", "n2", "n3", "n4", "delay"): if k in params: try: pstore[k] = int(params[k]) except Exception: pass except Exception as e: logging.debug(f"Pattern REST update failed: {e}") except Exception as e: logging.debug(f"Failed to select pattern {pattern}: {e}") def _init_color_slots(self): if not self.color_slots: self.color_slots = [ (255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255), (255, 255, 255), (128, 128, 128), ] def _rgb_to_hex(self, r, g, b): r = max(0, min(255, int(r))) g = max(0, min(255, int(g))) b = max(0, min(255, int(b))) return f"#{r:02x}{g:02x}{b:02x}" def open_color_window(self, slot_index: int): key = f"color_slot_{slot_index}" if key in self.pattern_windows: win = self.pattern_windows[key] if win.winfo_exists(): try: win.deiconify(); win.lift(); win.focus_force() except Exception: pass return win = tk.Toplevel(self.root) win.title(f"Color Slot {slot_index+1}") win.configure(bg=bg_color) try: win.geometry("700x360") except Exception: pass self.pattern_windows[key] = win def on_close(): try: win.withdraw() except Exception: try: win.destroy() except Exception: pass win.protocol("WM_DELETE_WINDOW", on_close) frm = ttk.Frame(win) frm.pack(padx=20, pady=20, fill="both", expand=True) r, g, b = self.color_slots[slot_index] def add_rgb_slider(row, label, initial, on_change_cb): lbl = tk.Label(frm, text=label, bg=bg_color, fg=fg_color, font=("Arial", 16)) lbl.grid(row=row, column=0, sticky="w", padx=12, pady=16) var = tk.IntVar(value=int(initial)) s = tk.Scale(frm, from_=0, to=255, orient="horizontal", showvalue=True, resolution=1, variable=var, length=560, sliderlength=28, width=20, bg=bg_color, fg=fg_color, highlightthickness=0, troughcolor=trough_color_brightness, command=lambda v: on_change_cb(int(float(v)))) s.grid(row=row, column=1, sticky="ew", padx=12, pady=16) frm.grid_columnconfigure(1, weight=1) return s def on_r(val): self._update_color_slot(slot_index, r=val) def on_g(val): self._update_color_slot(slot_index, g=val) def on_b(val): self._update_color_slot(slot_index, b=val) add_rgb_slider(0, "Red", r, on_r) add_rgb_slider(1, "Green", g, on_g) add_rgb_slider(2, "Blue", b, on_b) # Preview acts as a confirm/select button for this color slot def on_preview_click(): self.selected_color_slot = slot_index rr, gg, bb = self.color_slots[slot_index] # Persist selection via REST (first index is used for pattern color per api.md) self.persist_selected_indices([slot_index, 1]) # For immediate LED update, optional: send parameters API if required. Keeping color_change for now is removed per REST-only guidance. on_close() preview = tk.Button( frm, text="Use This Color", font=("Arial", 14), bg=self._rgb_to_hex(r, g, b), fg="#000000", width=16, height=2, command=on_preview_click, ) preview.grid(row=3, column=0, columnspan=2, pady=12) # Close button row btns = ttk.Frame(win) btns.pack(fill="x", padx=16, pady=10) close_btn = tk.Button(btns, text="Close", command=on_close, font=("Arial", 14), padx=16, pady=8) close_btn.pack(side="right") def refresh_preview(): rr, gg, bb = self.color_slots[slot_index] preview.configure(bg=self._rgb_to_hex(rr, gg, bb), fg="#000000" if (rr*0.299+gg*0.587+bb*0.114) > 186 else "#FFFFFF") self.root.after(200, refresh_preview) refresh_preview() def _update_color_slot(self, slot_index, r=None, g=None, b=None): cr, cg, cb = self.color_slots[slot_index] if r is not None: cr = int(r) if g is not None: cg = int(g) if b is not None: cb = int(b) self.color_slots[slot_index] = (cr, cg, cb) # Update palette colors on backend via REST (no immediate output) self.persist_palette() # Do not send color_change here; only send when user confirms via preview button # ---------------------- # Window geometry persist # ---------------------- def load_window_geometry(self): """Load last saved window geometry from config and apply it.""" try: if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, 'r') as f: cfg = json.load(f) geom = cfg.get('window_geometry') if isinstance(geom, dict): x = geom.get('x') y = geom.get('y') w = geom.get('w') h = geom.get('h') if all(isinstance(v, int) for v in (x, y, w, h)) and w > 0 and h > 0: self.root.geometry(f"{w}x{h}+{x}+{y}") except Exception as e: logging.debug(f"Failed to load window geometry: {e}") def save_window_geometry(self): """Save current window geometry to config.""" try: # Get current position and size self.root.update_idletasks() x = self.root.winfo_x() y = self.root.winfo_y() w = self.root.winfo_width() h = self.root.winfo_height() cfg = {} if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, 'r') as f: cfg = json.load(f) or {} except Exception: cfg = {} cfg['window_geometry'] = {'x': int(x), 'y': int(y), 'w': int(w), 'h': int(h)} with open(CONFIG_FILE, 'w') as f: json.dump(cfg, f, indent=2) except Exception as e: logging.debug(f"Failed to save window geometry: {e}") if __name__ == "__main__": app = UIClient() app.run()