From 1da2e30d4c1b7042d08a9709274df72155a1acab Mon Sep 17 00:00:00 2001 From: jimmy Date: Sun, 14 Sep 2025 05:23:46 +1200 Subject: [PATCH] midi: init read of CCs on startup (delay, brightness, RGB, beat enable); track bpm/pattern for GUI\nmain: integrate MidiHandler; add status panel for delay/brightness/RGB/pattern/BPM --- src/main.py | 734 ++++------------------------------------------------ src/midi.py | 66 ++++- 2 files changed, 115 insertions(+), 685 deletions(-) diff --git a/src/main.py b/src/main.py index 1ffb123..f8d1d47 100644 --- a/src/main.py +++ b/src/main.py @@ -5,8 +5,9 @@ import json from async_tkinter_loop import async_handler, async_mainloop from networking import WebSocketClient import color_utils -from settings import Settings + import time +from midi import MidiHandler # Import MidiHandler # Dark theme colors (unchanged) bg_color = "#2e2e2e" @@ -24,28 +25,22 @@ active_palette_color_border = "#FFD700" # Gold color class App: def __init__(self) -> None: - self.settings = Settings() self.root = tk.Tk() - self.root.attributes("-fullscreen", True) + # self.root.attributes("-fullscreen", True) self.root.configure(bg=bg_color) - # Debouncing variables (remain the same) - self.last_rgb_update_time = 0 - self.rgb_update_interval_ms = 100 - self.pending_rgb_update_id = None - - self.last_brightness_update_time = 0 - self.brightness_update_interval_ms = 100 - self.pending_brightness_update_id = None - - self.last_delay_update_time = 0 - self.delay_update_interval_ms = 100 - self.pending_delay_update_id = None - # --- WebSocketClient --- self.websocket_client = WebSocketClient("ws://192.168.4.1:80/ws") self.root.after(100, async_handler(self.websocket_client.connect)) + # --- MIDI Handler --- + MIDI_PORT_INDEX = 1 # Adjust as needed + WEBSOCKET_SERVER_URI = "ws://192.168.4.1:80/ws" + self.midi_handler = MidiHandler(MIDI_PORT_INDEX, WEBSOCKET_SERVER_URI) + self.midi_task: asyncio.Task | None = None + # Start MIDI in background + self.root.after(0, async_handler(self.start_midi)) + # Configure ttk style (unchanged) style = ttk.Style() style.theme_use("alt") @@ -54,691 +49,68 @@ class App: style.configure( "TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", 30), padding=[10, 5] ) - style.map("TNotebook.Tab", background=[("selected", active_bg_color)], foreground=[("selected", fg_color)]) - style.configure("TFrame", background=bg_color) - # Create Notebook for tabs (unchanged) - self.notebook = ttk.Notebook(self.root) - self.notebook.pack(expand=1, fill="both") + # --- Status Frame --- + status_frame = ttk.Frame(self.root) + status_frame.pack(padx=16, pady=16, fill="x") - self.tabs = {} - self.create_tabs() + self.lbl_delay = ttk.Label(status_frame, text="Delay: -") + self.lbl_brightness = ttk.Label(status_frame, text="Brightness: -") + self.lbl_r = ttk.Label(status_frame, text="R: -") + self.lbl_g = ttk.Label(status_frame, text="G: -") + self.lbl_b = ttk.Label(status_frame, text="B: -") + self.lbl_pattern = ttk.Label(status_frame, text="Pattern: -") + self.lbl_bpm = ttk.Label(status_frame, text="BPM: -") - self.notebook.bind("<>", self.on_tab_change) + for w in (self.lbl_delay, self.lbl_brightness, self.lbl_r, self.lbl_g, self.lbl_b, self.lbl_pattern, self.lbl_bpm): + w.pack(anchor="w") - # Add Reload Config Button (unchanged) - reload_button = tk.Button( - self.root, - text="Reload Config", - command=self.reload_config, - bg=active_bg_color, - fg=fg_color, - font=("Arial", 20), - padx=20, - pady=10, - relief=tk.FLAT, - ) - reload_button.pack(side=tk.BOTTOM, pady=20) + # 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) + @async_handler + async def start_midi(self): + # Launch MidiHandler.run() as a background task + if self.midi_task is None or self.midi_task.done(): + self.midi_task = asyncio.create_task(self.midi_handler.run()) + 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() - def create_tabs(self): - for tab_name in list(self.tabs.keys()): - self.notebook.forget(self.tabs[tab_name]) - del self.tabs[tab_name] - - for key, value in self.settings["lights"].items(): - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text=key) - self.create_light_control_widgets(tab, key, value["names"], value["settings"]) - self.tabs[key] = tab - - def create_light_control_widgets(self, tab, tab_name, ids, initial_settings): - slider_length = 800 - slider_width = 50 - - # Extract initial color, brightness, and delay - initial_colors = initial_settings.get("colors", ["#000000"]) - initial_hex_color = initial_colors[0] if initial_colors else "#000000" - initial_brightness = initial_settings.get("brightness", 127) - initial_delay = initial_settings.get("delay", 0) - initial_pattern = initial_settings.get("pattern", "on") - - initial_r, initial_g, initial_b = color_utils.hex_to_rgb(initial_hex_color) - - # Main frame to hold everything within the tab - main_tab_frame = tk.Frame(tab, bg=bg_color) - main_tab_frame.pack(expand=True, fill="both", padx=10, pady=10) - - # Left panel for sliders - slider_panel_frame = tk.Frame(main_tab_frame, bg=bg_color) - slider_panel_frame.pack(side=tk.LEFT, padx=10, pady=10) - - # Common slider configuration - slider_config = { - "from_": 255, - "to": 0, - "orient": tk.VERTICAL, - "length": slider_length, - "width": slider_width, - "bg": bg_color, - "fg": fg_color, - "highlightbackground": bg_color, - "activebackground": active_bg_color, - "resolution": 1, - "sliderlength": 70, - } - - # Red Slider - red_slider = tk.Scale(slider_panel_frame, label="Red", troughcolor=trough_color_red, **slider_config) - red_slider.set(initial_r) - red_slider.pack(side=tk.LEFT, padx=10) - red_slider.bind("", lambda _: self.schedule_update_rgb(tab)) - red_slider.bind("", lambda _: self.schedule_update_rgb(tab, force_send=True)) - - # Green Slider - green_slider = tk.Scale(slider_panel_frame, label="Green", troughcolor=trough_color_green, **slider_config) - green_slider.set(initial_g) - green_slider.pack(side=tk.LEFT, padx=10) - green_slider.bind("", lambda _: self.schedule_update_rgb(tab)) - green_slider.bind("", lambda _: self.schedule_update_rgb(tab, force_send=True)) - - # Blue Slider - blue_slider = tk.Scale(slider_panel_frame, label="Blue", troughcolor=trough_color_blue, **slider_config) - blue_slider.set(initial_b) - blue_slider.pack(side=tk.LEFT, padx=10) - blue_slider.bind("", lambda _: self.schedule_update_rgb(tab)) - blue_slider.bind("", lambda _: self.schedule_update_rgb(tab, force_send=True)) - - # Brightness Slider - brightness_slider = tk.Scale( - slider_panel_frame, label="Brightness", troughcolor=trough_color_brightness, **slider_config - ) - brightness_slider.set(initial_brightness) - brightness_slider.pack(side=tk.LEFT, padx=10) - brightness_slider.bind("", lambda _: self.schedule_update_brightness(tab)) - brightness_slider.bind("", lambda _: self.schedule_update_brightness(tab, force_send=True)) - - # Delay Slider - delay_slider_config = slider_config.copy() - delay_slider_config.update( - { - "from_": 1000, - "to": 0, - "resolution": 10, - "label": "Delay (ms)", - "troughcolor": trough_color_delay, - } - ) - delay_slider = tk.Scale(slider_panel_frame, **delay_slider_config) - delay_slider.set(initial_delay) - delay_slider.pack(side=tk.LEFT, padx=10) - delay_slider.bind("", lambda _: self.schedule_update_delay(tab)) - delay_slider.bind("", lambda _: self.schedule_update_delay(tab, force_send=True)) - - # Store references to widgets for this tab - tab.widgets = { - "red_slider": red_slider, - "green_slider": green_slider, - "blue_slider": blue_slider, - "brightness_slider": brightness_slider, - "delay_slider": delay_slider, - "selected_color_index": 0, # Default to the first color - } - tab.colors_in_palette = initial_colors.copy() # Store the list of hex colors for this tab - tab.color_swatch_frames = [] # To hold references to the color swatches - - # Right panel for IDs, Patterns, and NEW Color Palette - right_panel_frame = tk.Frame(main_tab_frame, bg=bg_color) - right_panel_frame.pack(side=tk.LEFT, padx=20, pady=10, anchor="n", expand=True, fill="both") - - # IDs section - MODIFIED TO BE SIDE-BY-SIDE - ids_frame = tk.Frame(right_panel_frame, bg=bg_color) - ids_frame.pack(pady=10, fill=tk.X) - tk.Label(ids_frame, text="Associated Names:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(pady=10) - - # New inner frame for the IDs to be displayed horizontally - ids_inner_frame = tk.Frame(ids_frame, bg=bg_color) - ids_inner_frame.pack(fill=tk.X, expand=True) # Pack this frame to fill available width - - for light_id in ids: - tk.Label(ids_inner_frame, text=str(light_id), font=("Arial", 18), bg=bg_color, fg=fg_color).pack( - side=tk.LEFT, padx=5, pady=2 - ) # Pack labels horizontally - - # --- New Frame to hold Patterns and Color Palette side-by-side --- - patterns_and_palette_frame = tk.Frame(right_panel_frame, bg=bg_color) - patterns_and_palette_frame.pack(pady=20, fill=tk.BOTH, expand=True) - - # Patterns section - patterns_frame = tk.Frame(patterns_and_palette_frame, bg=bg_color, bd=2, relief=tk.GROOVE) - patterns_frame.pack(side=tk.LEFT, padx=10, pady=5, fill=tk.BOTH, expand=True) # Pack to the left - tk.Label(patterns_frame, text="Patterns:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(pady=10) - - tab.pattern_buttons = {} - patterns = self.settings.get("patterns", []) - for pattern_name in patterns: - button = tk.Button( - patterns_frame, - text=pattern_name, - command=lambda p=pattern_name: self.send_pattern(tab_name, p), - bg=active_bg_color, - fg=fg_color, - font=("Arial", 18), - padx=15, - pady=5, - relief=tk.FLAT, - ) - button.pack(pady=5, fill=tk.X) - tab.pattern_buttons[pattern_name] = button - - self.highlight_pattern_button(tab, initial_pattern) - - # --- Color Palette Editor Section --- - color_palette_editor_frame = tk.Frame(patterns_and_palette_frame, bg=bg_color, bd=2, relief=tk.GROOVE) - color_palette_editor_frame.pack(side=tk.LEFT, padx=10, pady=5, fill=tk.BOTH, expand=True) # Pack to the left - tab.color_palette_editor_frame = color_palette_editor_frame # Store reference for update_ui_for_pattern - - tk.Label(color_palette_editor_frame, text="Color Palette:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack( - pady=10 - ) - - # Frame to hold color swatches (will be dynamic) - tab.color_swatches_container = tk.Frame(color_palette_editor_frame, bg=bg_color) - tab.color_swatches_container.pack(pady=5, fill=tk.BOTH, expand=True) - - # Buttons for Add/Remove Color - palette_buttons_frame = tk.Frame(color_palette_editor_frame, bg=bg_color) - palette_buttons_frame.pack(pady=10, fill=tk.X) - - add_color_button = tk.Button( - palette_buttons_frame, - text="Add Color", - command=lambda t=tab: self.add_color_to_palette(t), - bg=active_bg_color, - fg=fg_color, - font=("Arial", 16), - padx=10, - pady=5, - relief=tk.FLAT, - ) - add_color_button.pack(side=tk.LEFT, expand=True, padx=5) - - remove_color_button = tk.Button( - palette_buttons_frame, - text="Remove Selected", - command=lambda t=tab: self.remove_selected_color_from_palette(t), - bg=active_bg_color, - fg=fg_color, - font=("Arial", 16), - padx=10, - pady=5, - relief=tk.FLAT, - ) - remove_color_button.pack(side=tk.RIGHT, expand=True, padx=5) - - # Initial population of the color palette - self.refresh_color_palette_display(tab) - - # The initial call to update_ui_for_pattern now only sets slider values and highlights - self.update_ui_for_pattern(tab, initial_pattern) - - def refresh_color_palette_display(self, tab): - """Clears and repopulates the color swatches in the palette display.""" - # Clear existing swatches - for frame in tab.color_swatch_frames: - frame.destroy() - tab.color_swatch_frames.clear() - - for i, hex_color in enumerate(tab.colors_in_palette): - swatch_frame = tk.Frame( - tab.color_swatches_container, bg=hex_color, width=100, height=50, bd=2, relief=tk.SOLID - ) - swatch_frame.pack(pady=3, padx=5, fill=tk.X) - # Bind click to select this color for editing - swatch_frame.bind("", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx)) - - # Add a label inside to make it clickable too - swatch_label = tk.Label( - swatch_frame, - text=f"Color {i+1}", - bg=hex_color, - fg=color_utils.get_contrast_text_color(hex_color), - font=("Arial", 14), - width=5, - height=3, - ) - swatch_label.pack(expand=True, fill=tk.BOTH) - swatch_label.bind("", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx)) - - tab.color_swatch_frames.append(swatch_frame) - - # Re-highlight the currently selected color - self._highlight_selected_color_swatch(tab) - - def _highlight_selected_color_swatch(self, tab): - """Applies/removes highlight border to the selected color swatch.""" - current_index = tab.widgets["selected_color_index"] - for i, swatch_frame in enumerate(tab.color_swatch_frames): - if i == current_index: - swatch_frame.config(highlightbackground=active_palette_color_border, highlightthickness=3) - else: - swatch_frame.config(highlightbackground=swatch_frame.cget("bg"), highlightthickness=0) # Reset to no highlight - - def select_color_in_palette(self, tab, index: int): - """Selects a color in the palette, updates sliders, and highlights swatch. - This now also triggers an RGB update to the device.""" - if not (0 <= index < len(tab.colors_in_palette)): - return - - tab.widgets["selected_color_index"] = index - self._highlight_selected_color_swatch(tab) - - # Update RGB sliders with the selected color - hex_color = tab.colors_in_palette[index] - r, g, b = color_utils.hex_to_rgb(hex_color) - tab.widgets["red_slider"].set(r) - tab.widgets["green_slider"].set(g) - tab.widgets["blue_slider"].set(b) - - print(f"Selected color index {index}: {hex_color}") - - # Immediately send the update, as changing the selected color implies - # a desire to change the light's current color, regardless of pattern. - # This will also save the settings. - self.schedule_update_rgb(tab, force_send=True) - - def add_color_to_palette(self, tab): - """Adds a new black color to the palette and selects it, with a limit of 10 colors.""" - MAX_COLORS = 8 # Define the maximum number of colors allowed - - if len(tab.colors_in_palette) >= MAX_COLORS: - messagebox.showwarning("Color Limit Reached", f"You can add a maximum of {MAX_COLORS} colors to the palette.") - return - - # Simplified: just add black. If unique colors were required globally, - # more complex logic would be needed here. - tab.colors_in_palette.append("#000000") # Add black as default - self.refresh_color_palette_display(tab) - # Select the newly added color - self.select_color_in_palette(tab, len(tab.colors_in_palette) - 1) - self.save_current_tab_settings() # Save changes to settings.json - - def remove_selected_color_from_palette(self, tab): - """Removes the currently selected color from the palette.""" - current_index = tab.widgets["selected_color_index"] - if len(tab.colors_in_palette) <= 1: - messagebox.showwarning("Cannot Remove", "There must be at least one color in the palette.") - return - - if messagebox.askyesno("Confirm Delete", f"Are you sure you want to remove Color {current_index + 1}?"): - del tab.colors_in_palette[current_index] - # Adjust selected index if the removed color was the last one - if current_index >= len(tab.colors_in_palette): - tab.widgets["selected_color_index"] = len(tab.colors_in_palette) - 1 - if tab.widgets["selected_color_index"] < 0: # Should not happen with 1-color check - tab.widgets["selected_color_index"] = 0 - - self.refresh_color_palette_display(tab) - # Update sliders with the new selected color (if any) - if tab.colors_in_palette: - self.select_color_in_palette(tab, tab.widgets["selected_color_index"]) - else: # If palette became empty (shouldn't happen with 1-color check) - tab.widgets["red_slider"].set(0) - tab.widgets["green_slider"].set(0) - tab.widgets["blue_slider"].set(0) - - self.save_current_tab_settings() # Save changes to settings.json - - def update_ui_for_pattern(self, tab, current_pattern: str): - """ - Manages the state of the UI elements based on the selected pattern. - The Color Palette Editor is always visible. RGB sliders update - based on the currently selected color in the palette, or the first - color if the palette is empty or not in transition mode and a new tab/pattern is selected. - """ - # The color_palette_editor_frame is always packed, so no visibility control needed here. - - # When the pattern changes, we need to ensure the RGB sliders reflect - # the appropriate color based on the context. - - if tab.colors_in_palette: - # If in 'transition' mode, set sliders to the currently selected color in the palette. - if current_pattern == "transition": - self.select_color_in_palette(tab, tab.widgets["selected_color_index"]) - else: - # If not in 'transition' mode, but a color is selected, update sliders to that. - # Or, if this is a fresh load/tab change, ensure it's the first color. - # This ensures the sliders consistently show the color that will be sent - # for 'on'/'blink' based on the palette's first entry. - hex_color = tab.colors_in_palette[tab.widgets["selected_color_index"]] - r, g, b = color_utils.hex_to_rgb(hex_color) - tab.widgets["red_slider"].set(r) - tab.widgets["green_slider"].set(g) - tab.widgets["blue_slider"].set(b) - self._highlight_selected_color_swatch(tab) # Re-highlight even if index didn't change - else: - # Handle empty palette scenario (shouldn't happen with default ["#000000"]) - tab.widgets["red_slider"].set(0) - tab.widgets["green_slider"].set(0) - tab.widgets["blue_slider"].set(0) - tab.widgets["selected_color_index"] = 0 # Ensure index is valid - self._highlight_selected_color_swatch(tab) - - # Brightness and Delay sliders are always visible. - - def highlight_pattern_button(self, tab_widget, active_pattern_name): - if hasattr(tab_widget, "pattern_buttons"): - for pattern_name, button in tab_widget.pattern_buttons.items(): - if pattern_name == active_pattern_name: - button.config(bg=highlight_pattern_color) - else: - button.config(bg=active_bg_color) - - def on_tab_change(self, event): - selected_tab_name = self.notebook.tab(self.notebook.select(), "text") - current_tab_widget = self.notebook.nametowidget(self.notebook.select()) - - initial_settings = self.settings["lights"][selected_tab_name]["settings"] - - # Ensure current_tab_widget has the necessary attributes - if not hasattr(current_tab_widget, "colors_in_palette"): - # This tab might not have been fully initialized yet, or recreated - # In a full reload, create_tabs ensures it is. - return - - # Update the local colors_in_palette list for the tab - current_tab_widget.colors_in_palette = initial_settings.get("colors", ["#000000"]).copy() - current_tab_widget.widgets["selected_color_index"] = 0 # Default to first color - - # Refresh the color palette display and select the first color - self.refresh_color_palette_display(current_tab_widget) - if current_tab_widget.colors_in_palette: - self.select_color_in_palette(current_tab_widget, 0) - else: # If palette became empty (shouldn't happen with default ["#000000"]) - current_tab_widget.widgets["red_slider"].set(0) - current_tab_widget.widgets["green_slider"].set(0) - current_tab_widget.widgets["blue_slider"].set(0) - - # Update brightness and delay sliders - current_tab_widget.widgets["brightness_slider"].set(initial_settings.get("brightness", 127)) - current_tab_widget.widgets["delay_slider"].set(initial_settings.get("delay", 0)) - - # Highlight the active pattern button - initial_pattern = initial_settings.get("pattern", "on") - self.highlight_pattern_button(current_tab_widget, initial_pattern) - - # Update UI visibility based on the current pattern - self.update_ui_for_pattern(current_tab_widget, initial_pattern) - - def reload_config(self): - print("Reloading configuration...") - self.settings = Settings() - self.create_tabs() - # After recreating, ensure the currently selected tab's sliders are updated - # Trigger on_tab_change manually for the currently selected tab - self.on_tab_change(None) - - # --- Debouncing functions (no change to core logic, just how they call update_rgb) --- - def schedule_update_rgb(self, tab, force_send=False): - current_time = time.time() * 1000 - if force_send: - if self.pending_rgb_update_id: - self.root.after_cancel(self.pending_rgb_update_id) - self.pending_rgb_update_id = None - self.update_rgb(tab) - self.last_rgb_update_time = current_time - elif current_time - self.last_rgb_update_time >= self.rgb_update_interval_ms: - if self.pending_rgb_update_id: - self.root.after_cancel(self.pending_rgb_update_id) - self.pending_rgb_update_id = None - self.update_rgb(tab) - self.last_rgb_update_time = current_time - else: - if self.pending_rgb_update_id: - self.root.after_cancel(self.pending_rgb_update_id) - time_to_wait = int(self.rgb_update_interval_ms - (current_time - self.last_rgb_update_time)) - self.pending_rgb_update_id = self.root.after(time_to_wait, lambda: self.update_rgb(tab)) - - def schedule_update_brightness(self, tab, force_send=False): - current_time = time.time() * 1000 - if force_send: - if self.pending_brightness_update_id: - self.root.after_cancel(self.pending_brightness_update_id) - self.pending_brightness_update_id = None - self.update_brightness(tab) - self.last_brightness_update_time = current_time - elif current_time - self.last_brightness_update_time >= self.brightness_update_interval_ms: - if self.pending_brightness_update_id: - self.root.after_cancel(self.pending_brightness_update_id) - self.pending_brightness_update_id = None - self.update_brightness(tab) - self.last_brightness_update_time = current_time - else: - if self.pending_brightness_update_id: - self.root.after_cancel(self.pending_brightness_update_id) - time_to_wait = int(self.brightness_update_interval_ms - (current_time - self.last_brightness_update_time)) - self.pending_brightness_update_id = self.root.after(time_to_wait, lambda: self.update_brightness(tab)) - - def schedule_update_delay(self, tab, force_send=False): - current_time = time.time() * 1000 - if force_send: - if self.pending_delay_update_id: - self.root.after_cancel(self.pending_delay_update_id) - self.pending_delay_update_id = None - self.update_delay(tab) - self.last_delay_update_time = current_time - elif current_time - self.last_delay_update_time >= self.delay_update_interval_ms: - if self.pending_delay_update_id: - self.root.after_cancel(self.pending_delay_update_id) - self.pending_delay_update_id = None - self.update_delay(tab) - self.last_delay_update_time = current_time - else: - if self.pending_delay_update_id: - self.root.after_cancel(self.pending_delay_update_id) - time_to_wait = int(self.delay_update_interval_ms - (current_time - self.last_delay_update_time)) - self.pending_delay_update_id = self.root.after(time_to_wait, lambda: self.update_delay(tab)) - # --- Asynchronous Update Functions --- @async_handler async def update_rgb(self, tab): - """Update the currently selected color in the palette and send to the server.""" - try: - red_slider = tab.widgets["red_slider"] - green_slider = tab.widgets["green_slider"] - blue_slider = tab.widgets["blue_slider"] + pass - r = red_slider.get() - g = green_slider.get() - b = blue_slider.get() + def update_status_labels(self): + # 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) + pattern = getattr(self.midi_handler, 'current_pattern', '') or '-' + bpm = getattr(self.midi_handler, 'current_bpm', None) + bpm_text = f"{bpm:.2f}" if isinstance(bpm, (float, int)) else "-" - hex_color = f"#{r:02x}{g:02x}{b:02x}" - print(f"Updating selected color to: {hex_color}") + self.lbl_delay.config(text=f"Delay: {delay}") + self.lbl_brightness.config(text=f"Brightness: {brightness}") + self.lbl_r.config(text=f"R: {r}") + self.lbl_g.config(text=f"G: {g}") + self.lbl_b.config(text=f"B: {b}") + self.lbl_pattern.config(text=f"Pattern: {pattern}") + self.lbl_bpm.config(text=f"BPM: {bpm_text}") - selected_color_index = tab.widgets["selected_color_index"] - if 0 <= selected_color_index < len(tab.colors_in_palette): - tab.colors_in_palette[selected_color_index] = hex_color - self.refresh_color_palette_display(tab) # Update swatch immediately - - selected_server = self.notebook.tab(self.notebook.select(), "text") - names = self.settings["lights"][selected_server]["names"] - - # Determine which colors to send based on the current pattern. - current_pattern = self.settings["lights"][selected_server]["settings"].get("pattern", "on") - colors_to_send = [] - - if current_pattern == "transition": - colors_to_send = tab.colors_in_palette.copy() - elif current_pattern in ["on", "blink", "theater_chase", "flicker"]: # Add other patterns that use a single color - if tab.colors_in_palette: - # For non-transition patterns, the device typically uses only the first color. - # However, if a user picks a color from the palette, we want THAT color to be the one - # sent and active. So, the selected color from the palette *becomes* the first color - # in the list we send to the device for these modes. - # This ensures the light matches the selected palette color. - colors_to_send = [hex_color] # Send the color currently set by the sliders - else: - colors_to_send = ["#000000"] # Default if palette is empty - else: # For other patterns like "off", "rainbow" where colors might not be primary - # We still want to send the *current* palette state for saving, - # but the device firmware might ignore it for these patterns. - colors_to_send = tab.colors_in_palette.copy() - - payload = { - "save": True, # Always save this change to config - "names": names, - "settings": { - "colors": colors_to_send, # This now dynamically changes based on pattern - "brightness": tab.widgets["brightness_slider"].get(), - "delay": tab.widgets["delay_slider"].get(), - "pattern": current_pattern, # Always send the current pattern - }, - } - - # Update the settings object with the new color list (and potentially other synced values) - self.settings["lights"][selected_server]["settings"]["colors"] = tab.colors_in_palette.copy() - self.settings["lights"][selected_server]["settings"]["brightness"] = tab.widgets["brightness_slider"].get() - self.settings["lights"][selected_server]["settings"]["delay"] = tab.widgets["delay_slider"].get() - self.settings.save() - - await self.websocket_client.send_data(payload) - print(f"Sent RGB payload: {payload}") - - except Exception as e: - print(f"Error updating RGB: {e}") - - @async_handler - async def update_brightness(self, tab): - try: - brightness_slider = tab.widgets["brightness_slider"] - brightness = brightness_slider.get() - print(f"Brightness: {brightness}") - - selected_server = self.notebook.tab(self.notebook.select(), "text") - names = self.settings["lights"][selected_server]["names"] - - payload = { - "save": True, - "names": names, - "settings": { - "brightness": brightness, - }, - } - # Update the settings object with the new brightness - self.settings["lights"][selected_server]["settings"]["brightness"] = brightness - self.settings.save() - await self.websocket_client.send_data(payload) - print(f"Sent brightness payload: {payload}") - except Exception as e: - print(f"Error updating brightness: {e}") - - @async_handler - async def update_delay(self, tab): - try: - delay_slider = tab.widgets["delay_slider"] - delay = delay_slider.get() - print(f"Delay: {delay}") - - selected_server = self.notebook.tab(self.notebook.select(), "text") - names = self.settings["lights"][selected_server]["names"] - payload = { - "save": True, - "names": names, - "settings": { - "delay": delay, - }, - } - # Update the settings object with the new delay - self.settings["lights"][selected_server]["settings"]["delay"] = delay - self.settings.save() - await self.websocket_client.send_data(payload) - print(f"Sent delay payload: {payload}") - except Exception as e: - print(f"Error updating delay: {e}") - - @async_handler - async def send_pattern(self, tab_name: str, pattern_name: str): - try: - names = self.settings["lights"][tab_name]["names"] - # Get the actual tab widget to access its `colors_in_palette` and other attributes - current_tab_widget = None - for key, tab_widget in self.tabs.items(): - if key == tab_name: - current_tab_widget = tab_widget - break - - if not current_tab_widget: - print(f"Error: Could not find tab widget for {tab_name}") - return - - current_settings_for_tab = self.settings["lights"][tab_name]["settings"] - - payload_settings = { - "pattern": pattern_name, - "brightness": current_settings_for_tab.get("brightness", 127), - "delay": current_settings_for_tab.get("delay", 0), - } - - # Determine colors to send based on the *newly selected* pattern - if pattern_name == "transition": - payload_settings["colors"] = current_tab_widget.colors_in_palette.copy() - elif pattern_name in ["on", "blink"]: # Add other patterns that use a single color here - # When switching TO 'on' or 'blink', ensure the color sent is the one - # currently displayed on the sliders (which reflects the selected palette color). - r = current_tab_widget.widgets["red_slider"].get() - g = current_tab_widget.widgets["green_slider"].get() - b = current_tab_widget.widgets["blue_slider"].get() - hex_color_from_sliders = f"#{r:02x}{g:02x}{b:02x}" - payload_settings["colors"] = [hex_color_from_sliders] - else: - # For other patterns, send the full palette, device might ignore or use default - payload_settings["colors"] = current_tab_widget.colors_in_palette.copy() - - payload = { - "save": True, - "names": names, - "settings": payload_settings, - } - - # Update the settings object with the new pattern and current colors/brightness/delay - self.settings["lights"][tab_name]["settings"]["pattern"] = pattern_name - # Always save the full current palette state in settings. - self.settings["lights"][tab_name]["settings"]["colors"] = current_tab_widget.colors_in_palette.copy() - self.settings.save() - - self.highlight_pattern_button(current_tab_widget, pattern_name) - self.update_ui_for_pattern(current_tab_widget, pattern_name) # Update UI based on new pattern - - await self.websocket_client.send_data(payload) - print(f"Sent pattern payload: {payload}") - except Exception as e: - print(f"Error sending pattern: {e}") - - def save_current_tab_settings(self): - """Saves the current state of the active tab's settings (colors, brightness, delay, pattern) to config.""" - selected_server = self.notebook.tab(self.notebook.select(), "text") - current_tab_widget = self.notebook.nametowidget(self.notebook.select()) - - if not hasattr(current_tab_widget, "colors_in_palette"): - return # Tab not fully initialized yet - - # Update settings for the current tab in the self.settings object - self.settings["lights"][selected_server]["settings"]["colors"] = current_tab_widget.colors_in_palette - self.settings["lights"][selected_server]["settings"]["brightness"] = current_tab_widget.widgets["brightness_slider"].get() - self.settings["lights"][selected_server]["settings"]["delay"] = current_tab_widget.widgets["delay_slider"].get() - # The pattern is updated in send_pattern already, but ensure consistency - # For simplicity, we assume send_pattern is the primary way to change pattern. - - self.settings.save() - print(f"Saved settings for {selected_server}") + # reschedule + self.root.after(200, self.update_status_labels) if __name__ == "__main__": diff --git a/src/midi.py b/src/midi.py index 6c3f805..521a9b8 100644 --- a/src/midi.py +++ b/src/midi.py @@ -4,6 +4,7 @@ import networking import socket import json import logging # Added logging import +import time # Added for initial state read # Configure logging DEBUG_MODE = True # Set to False for INFO level logging @@ -29,6 +30,19 @@ class MidiHandler: self.beat_sending_enabled = True # New: Local flag for beat sending self.sound_control_host = SOUND_CONTROL_HOST self.sound_control_port = SOUND_CONTROL_PORT + # RGB controlled by CC 30/31/32 (default green) + self.color_r = 0 + self.color_g = 255 + self.color_b = 0 + # Current state for GUI display + self.current_bpm: float | None = None + self.current_pattern: str = "" + + def _current_color_hex(self) -> str: + r = max(0, min(255, int(self.color_r))) + g = max(0, min(255, int(self.color_g))) + b = max(0, min(255, int(self.color_b))) + return f"#{r:02x}{g:02x}{b:02x}" async def _send_reset_to_sound(self): try: @@ -61,13 +75,14 @@ class MidiHandler: try: # Attempt to parse as float (BPM) from sound.py bpm_value = float(message) + self.current_bpm = bpm_value # Construct JSON message using the current MIDI-controlled delay and brightness json_message = { "names": ["0"], "settings": { "pattern": "pulse", "delay": self.delay, # Use MIDI-controlled delay - "colors": ["#00ff00"], + "colors": [self._current_color_hex()], "brightness": self.brightness, "num_leds": 200, }, @@ -100,6 +115,32 @@ class MidiHandler: async with server: await server.serve_forever() + async def _read_initial_cc_state(self, port, timeout_s: float = 0.5): + """Read initial CC values from the MIDI device for a short period to populate state.""" + start = time.time() + while time.time() - start < timeout_s: + msg = port.receive(block=False) + if msg and msg.type == 'control_change': + if msg.control == 36: + self.delay = msg.value * 4 + logging.info(f"[Init] Delay set to {self.delay} ms from CC36") + elif msg.control == 37: + self.brightness = round((msg.value / 127) * 100) + logging.info(f"[Init] Brightness set to {self.brightness} from CC37") + elif msg.control == 30: + self.color_r = round((msg.value / 127) * 255) + logging.info(f"[Init] Red set to {self.color_r} from CC30") + elif msg.control == 31: + self.color_g = round((msg.value / 127) * 255) + logging.info(f"[Init] Green set to {self.color_g} from CC31") + elif msg.control == 32: + self.color_b = round((msg.value / 127) * 255) + logging.info(f"[Init] Blue set to {self.color_b} from CC32") + elif msg.control == 27: + self.beat_sending_enabled = (msg.value == 127) + logging.info(f"[Init] Beat sending {'ENABLED' if self.beat_sending_enabled else 'DISABLED'} from CC27") + await asyncio.sleep(0.001) + async def _midi_listener(self): logging.info("Midi function") # Changed to info """ @@ -125,6 +166,8 @@ class MidiHandler: try: with mido.open_input(midi_port_name) as port: logging.info(f"MIDI port '{midi_port_name}' opened. Press Ctrl+C to stop.") # Changed to info + # Read initial controller state briefly + await self._read_initial_cc_state(port) while True: msg = port.receive(block=False) # Non-blocking read if msg: @@ -137,24 +180,26 @@ class MidiHandler: match msg.note: case 48: # Original Note 48 for 'pulse' pattern_name = "pulse" + self.current_pattern = pattern_name await self.ws_client.send_data({ "names": ["0"], "settings": { "pattern": pattern_name, "delay": self.delay, # Use MIDI-controlled delay - "colors": ["#00ff00"], + "colors": [self._current_color_hex()], "brightness": self.brightness, "num_leds": 120, } }) case 49: # Original Note 49 for 'theater_chase' pattern_name = "theater_chase" + self.current_pattern = pattern_name await self.ws_client.send_data({ "names": ["0"], "settings": { "pattern": pattern_name, "delay": self.delay, # Use MIDI-controlled delay - "colors": ["#00ff00"], + "colors": [self._current_color_hex()], "brightness": self.brightness, "num_leds": 120, "on_width": 10, @@ -165,13 +210,14 @@ class MidiHandler: }) case 50: # Original Note 50 for 'alternating' pattern_name = "alternating" + self.current_pattern = pattern_name logging.debug("Triggering Alternating Pattern") # Changed to debug await self.ws_client.send_data({ "names": ["0"], "settings": { "pattern": pattern_name, "delay": self.delay, # Use MIDI-controlled delay - "colors": ["#00ff00", "#0000ff"], + "colors": [self._current_color_hex(), "#0000ff"], "brightness": self.brightness, "num_leds": 120, "n1": 10, @@ -199,6 +245,18 @@ class MidiHandler: # Map 0-127 to 0-100 brightness scale self.brightness = round((msg.value / 127) * 100) logging.info(f"Brightness set to {self.brightness} by MIDI controller") + case 30: + # Red 0-127 -> 0-255 + self.color_r = round((msg.value / 127) * 255) + logging.info(f"Red set to {self.color_r}") + case 31: + # Green 0-127 -> 0-255 + self.color_g = round((msg.value / 127) * 255) + logging.info(f"Green set to {self.color_g}") + case 32: + # Blue 0-127 -> 0-255 + self.color_b = round((msg.value / 127) * 255) + logging.info(f"Blue set to {self.color_b}") await asyncio.sleep(0.001) # Important: Yield control to asyncio event loop