Files
lighting-controller/src/ui_client.py

1256 lines
51 KiB
Python

#!/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("<<ComboboxSelected>>", 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("<Button-1>", _set_target_color1)
self.color2_preview.bind("<Button-1>", _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("<Button-1>", 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("<Button-1>", 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()