1256 lines
51 KiB
Python
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()
|