Files
lighting-controller/src/main.py
Jimmy ed5bbb8c18 Add MIDI device persistence functionality
- Add configuration file system to remember MIDI device selection
- Load saved MIDI device preference on application startup
- Automatically save MIDI device selection when changed
- Handle device disconnection gracefully with fallback
- Smart initialization with validation and error handling
- Create config.json for storing device preferences
- Improve user experience by eliminating need to re-select device
2025-09-27 23:29:06 +12:00

515 lines
20 KiB
Python

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