From c77fd30f8fabf3a2b458ffa25edab66c7c821e43 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 12 Jul 2025 00:55:30 +1200 Subject: [PATCH] Done a heap --- color_utils.py | 83 ++++-- config.py | 49 ---- main.py | 748 ++++++++++++++++++++++++++++++++++++++++++++----- networking.py | 68 +++-- settings.json | 26 ++ settings.py | 27 ++ 6 files changed, 841 insertions(+), 160 deletions(-) delete mode 100644 config.py create mode 100644 settings.json create mode 100644 settings.py diff --git a/color_utils.py b/color_utils.py index 831e6d1..b5b849c 100644 --- a/color_utils.py +++ b/color_utils.py @@ -1,23 +1,68 @@ -def adjust_brightness(color, brightness): - """Adjust brightness of an RGB color.""" - r, g, b = color - return (int(r * brightness/255), int(g * brightness/255), int(b * brightness/255)) +def adjust_brightness(rgb_color, brightness): + r, g, b = rgb_color + # Convert 0-255 brightness to a scale of 0-1 + scale_factor = brightness / 255.0 -def rgb_to_hex(color): - """Convert an RGB color to hex format.""" - return '#{:02x}{:02x}{:02x}'.format(color[0], color[1], color[2]) + adjusted_r = int(r * scale_factor) + adjusted_g = int(g * scale_factor) + adjusted_b = int(b * scale_factor) + + # Ensure values are within 0-255 + adjusted_r = max(0, min(255, adjusted_r)) + adjusted_g = max(0, min(255, adjusted_g)) + adjusted_b = max(0, min(255, adjusted_b)) + + return (adjusted_r, adjusted_g, adjusted_b) -def generate_color_transition(start_color, end_color, steps): - """Generate a list of colors transitioning from start_color to end_color.""" - r1, g1, b1 = start_color - r2, g2, b2 = end_color +def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: + """Converts a hex color string (e.g., "#RRGGBB") to an RGB tuple.""" + hex_color = hex_color.lstrip('#') + return int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) - transition_colors = [] - for i in range(steps): - r = r1 + (r2 - r1) * i // (steps - 1) - g = g1 + (g2 - g1) * i // (steps - 1) - b = b1 + (b2 - b1) * i // (steps - 1) - transition_colors.append((r, g, b)) - - return transition_colors +def rgb_to_hex(r: int, g: int, b: int) -> str: + """Converts an RGB tuple to a hex color string (e.g., "#RRGGBB").""" + return f"#{r:02x}{g:02x}{b:02x}" + +def get_contrast_text_color(background_hex_color: str) -> str: + """ + Determines whether black or white text is more readable on a given background color. + Uses the WCAG 2.0 contrast recommendations (Luminosity calculation). + """ + r, g, b = hex_to_rgb(background_hex_color) + + # Convert RGB to sRGB (0-1 range) + # The linear RGB values are normalized by dividing by 255 + r_linear = r / 255.0 + g_linear = g / 255.0 + b_linear = b / 255.0 + + # Apply the sRGB to linear conversion for gamma correction + # This is a simplified approximation for readability, a more accurate one involves if/else for values <= 0.03928 + # For a general "light vs dark" determination, this simplified approach is often sufficient. + # The formula used here is often simplified as (R*0.299 + G*0.587 + B*0.114) for quick luminance. + # A more precise relative luminance (L) calculation: + def srgb_to_linear(c): + if c <= 0.03928: + return c / 12.92 + else: + return ((c + 0.055) / 1.055) ** 2.4 + + L = (0.2126 * srgb_to_linear(r_linear) + + 0.7152 * srgb_to_linear(g_linear) + + 0.0722 * srgb_to_linear(b_linear)) + + # For general UI elements, a luminance threshold around 0.179 (sqrt(0.032)) is often used + # or simply checking if (R*0.299 + G*0.587 + B*0.114) is > 186 for light background / dark text + # A common rule of thumb for perceived brightness (closer to the one used in many UIs): + # (R*299 + G*587 + B*114) / 1000 + # Let's use a simpler luminance check based on the first example's intention: + # If the perceived brightness is above a certain threshold, use black text. Otherwise, use white. + + # A simpler luminance check often used for text contrast: + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + + if luminance > 0.5: # Adjust this threshold as needed for perceived contrast + return "black" + else: + return "white" diff --git a/config.py b/config.py deleted file mode 100644 index ab6d54b..0000000 --- a/config.py +++ /dev/null @@ -1,49 +0,0 @@ -import json -import os - -default_config = { - 'color': (255, 0, 0), - 'brightness': 1.0, - 'color_format': 'RGB', - 'servers': [('192.168.0.201', 80)] -} - -config_file = 'config.json' - -class ConfigHandler: - def load_config(self): - """Load color, brightness, format, and server settings from config file.""" - if os.path.exists(config_file): - with open(config_file, 'r') as f: - return json.load(f) - else: - return default_config - - def save_config(self, color, brightness, color_format, servers): - """Save color, brightness, format, and server settings to config file.""" - config = { - 'color': color, - 'brightness': brightness, - 'color_format': color_format, - 'servers': servers - } - with open(config_file, 'w') as f: - json.dump(config, f) - print("Configuration saved.") - - def add_server(self, ip, port): - """Add a new server IP and port.""" - config = self.load_config() - config['servers'].append((ip, port)) - self.save_config(config['color'], config['brightness'], config['color_format'], config['servers']) - print(f"Server {ip}:{port} added.") - - def remove_server(self, ip, port): - """Remove an existing server IP and port.""" - config = self.load_config() - if (ip, port) in config['servers']: - config['servers'].remove((ip, port)) - self.save_config(config['color'], config['brightness'], config['color_format'], config['servers']) - print(f"Server {ip}:{port} removed.") - else: - print(f"Server {ip}:{port} not found.") diff --git a/main.py b/main.py index 9fba8a9..9a05fa3 100644 --- a/main.py +++ b/main.py @@ -1,118 +1,726 @@ import asyncio import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox # Import messagebox for confirmations import json from async_tkinter_loop import async_handler, async_mainloop -from networking import send_to_server +from networking import WebSocketClient import color_utils +from settings import Settings +import time +# 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.settings = Settings() self.root = tk.Tk() - self.root.attributes('-fullscreen', True) - self.root.configure(bg=bg_color) # Set background color + self.root.attributes("-fullscreen", True) + self.root.configure(bg=bg_color) - # List of servers (IP, Port) - self.lightgroups = { - "light1": {"ids": [0], "settings": {"color": "#00ff00"}}, - "light2": {"ids": [0], "settings": {"color": "#ff0000"}} - } + # Debouncing variables (remain the same) + self.last_rgb_update_time = 0 + self.rgb_update_interval_ms = 100 + self.pending_rgb_update_id = None - # Create Notebook for tabs + 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)) + + # 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] + ) + 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") - # Create a tab for each server self.tabs = {} - for key, value in self.lightgroups.items(): - tab = ttk.Frame(self.notebook) - self.notebook.add(tab, text=key) - self.create_sliders(tab) - self.tabs[key] = tab + self.create_tabs() + + self.notebook.bind("<>", self.on_tab_change) + + # 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) + + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) async_mainloop(self.root) - def create_sliders(self, tab): - """Create sliders for each tab.""" + def on_closing(self): + print("Closing application...") + 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 - # Red Slider - red_slider = tk.Scale(tab, from_=255, to=0, orient=tk.VERTICAL, length=slider_length, width=slider_width, label="Red") - red_slider.set(0) - red_slider.pack(side=tk.LEFT, padx=10) - red_slider.bind("", lambda _: self.update_colour(tab)) + # Extract initial color, brightness, and delay + # The 'colors' entry can now be a list. We'll pick the first one for initial display. + 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") - # Green Slider - green_slider = tk.Scale(tab, from_=255, to=0, orient=tk.VERTICAL, length=slider_length, width=slider_width, label="Green") - green_slider.set(0) - green_slider.pack(side=tk.LEFT, padx=10) - green_slider.bind("", lambda _: self.update_colour(tab)) + initial_r, initial_g, initial_b = color_utils.hex_to_rgb(initial_hex_color) - # Blue Slider - blue_slider = tk.Scale(tab, from_=255, to=0, orient=tk.VERTICAL, length=slider_length, width=slider_width, label="Blue") - blue_slider.set(0) - blue_slider.pack(side=tk.LEFT, padx=10) - blue_slider.bind("", lambda _: self.update_colour(tab)) + # 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) - # Brightness Slider - brightness_slider = tk.Scale(tab, from_=255, to=0, orient=tk.VERTICAL, length=slider_length, width=slider_width, label="Brightness") - brightness_slider.set(127) - brightness_slider.pack(side=tk.LEFT, padx=10) - brightness_slider.bind("", lambda _: self.update_colour(tab)) + # 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) - tab.widgets = { - 'red_slider': red_slider, - 'green_slider': green_slider, - 'blue_slider': blue_slider, - 'brightness_slider': brightness_slider, + # 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 (unchanged) + 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) + for light_id in ids: + tk.Label(ids_frame, text=str(light_id), font=("Arial", 18), bg=bg_color, fg=fg_color).pack(pady=2) + + # --- 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=2, 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=10, + height=2, + ) + 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.""" + 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}") + + 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 = 10 # 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 + + 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 if check above works + 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 now always visible when 'transition' is selected, + and RGB sliders update based on the selected color in the palette. + When not 'transition', RGB sliders revert to the first color in settings. + """ + # The color_palette_editor_frame is now *always* packed in create_light_control_widgets + # when patterns_and_palette_frame is created with side-by-side packing. + # So we no longer need to pack/pack_forget it here. + # Its visibility is handled by its initial creation and packing alongside the patterns. + + # If the pattern is "transition", select the current color in the palette + # and ensure the RGB sliders reflect that color. + if current_pattern == "transition": + # This handles refreshing the display and setting sliders to the selected color + self.refresh_color_palette_display(tab) + self.select_color_in_palette(tab, tab.widgets["selected_color_index"]) + else: + # When switching away from transition, ensure RGB sliders show the first color from settings + # and reset selected_color_index. + initial_colors = self.settings["lights"][self.notebook.tab(self.notebook.select(), "text")]["settings"].get( + "colors", ["#000000"] + ) + initial_hex_color = initial_colors[0] if initial_colors else "#000000" + r, g, b = color_utils.hex_to_rgb(initial_hex_color) + tab.widgets["red_slider"].set(r) + tab.widgets["green_slider"].set(g) + tab.widgets["blue_slider"].set(b) + tab.widgets["selected_color_index"] = 0 # Reset selected color index to the first (default) + self._highlight_selected_color_swatch( + tab + ) # Remove highlight if active color is no longer relevant for editing + # (or just highlight the 0th if you want) + + # 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_colour(self, tab): - """Update color based on the slider values and send to the selected server.""" + async def update_rgb(self, tab): + """Update the currently selected color in the palette and send to the server.""" try: - # Retrieve slider values - red_slider = tab.widgets['red_slider'] - green_slider = tab.widgets['green_slider'] - blue_slider = tab.widgets['blue_slider'] - brightness_slider = tab.widgets['brightness_slider'] + red_slider = tab.widgets["red_slider"] + green_slider = tab.widgets["green_slider"] + blue_slider = tab.widgets["blue_slider"] r = red_slider.get() g = green_slider.get() b = blue_slider.get() - brightness = brightness_slider.get() - # Adjust brightness - color = color_utils.adjust_brightness((r, g, b), brightness) - print(f"Adjusted color: {color}, Brightness: {brightness}") + hex_color = f"#{r:02x}{g:02x}{b:02x}" + print(f"Updating selected color to: {hex_color}") - # Convert RGB to hex color - hex_color = f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}" + 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 - # Get the selected server selected_server = self.notebook.tab(self.notebook.select(), "text") - ids = self.lightgroups[selected_server]["ids"] + names = self.settings["lights"][selected_server]["names"] + + # ALWAYS send the full current palette, or at least the first color, + # along with other relevant settings, when an RGB slider is moved. + # The device firmware will interpret 'colors' based on its current pattern. + + # Determine which colors to send. It's generally safest to send the + # full current palette, as the device might need all of them. + colors_to_send = tab.colors_in_palette.copy() # Send a copy to be safe - # Construct WebSocket payload payload = { - "save": True, - "ids": [0], + "save": True, # Always save this change to config + "names": names, "settings": { - "colors": [hex_color], # Use the dynamically calculated hex color - "brightness": brightness, # Use the brightness slider value - "pattern": "on" - } + "colors": colors_to_send, + # We might also want to send the current brightness and delay + # to ensure the device has the complete state, or at least + # ensures these don't get 'unset' if they weren't explicitly changed. + # This depends on your firmware's expected payload. + "brightness": tab.widgets["brightness_slider"].get(), + "delay": tab.widgets["delay_slider"].get(), + # Also include the current pattern, so the device knows how to apply colors + "pattern": self.settings["lights"][selected_server]["settings"].get("pattern", "on"), + }, } - # Send the payload to the server - await send_to_server(payload) - print(f"Sent payload: {payload}") + # 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() + # Also ensure brightness, delay, and pattern are up-to-date in settings before saving + self.settings["lights"][selected_server]["settings"]["brightness"] = tab.widgets["brightness_slider"].get() + self.settings["lights"][selected_server]["settings"]["delay"] = tab.widgets["delay_slider"].get() + # Pattern is generally set by the pattern buttons, but including it in the save here + # for completeness might be useful depending on your app's state management. + # self.settings["lights"][selected_server]["settings"]["pattern"] = current_pattern # Already updated by send_pattern + self.settings.save() + + await self.websocket_client.send_data(payload) + print(f"Sent RGB payload: {payload}") + except Exception as e: - print(f"Error updating color: {e}") - # Optionally, display the error in the GUI + 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), + } + + # Only include "colors" in the payload if the pattern specifically uses them + # For "transition", send the entire palette + # For "on", "off", "blink", usually just the first color from the palette is relevant + if pattern_name == "transition": + # Ensure we send the *current state* of the palette + payload_settings["colors"] = current_tab_widget.colors_in_palette + elif pattern_name in ["on", "blink"]: # Add other patterns that use a single color here + if current_tab_widget.colors_in_palette: + payload_settings["colors"] = [current_tab_widget.colors_in_palette[0]] + else: + payload_settings["colors"] = ["#000000"] # Default if palette is empty + # For patterns like "off" or "rainbow", "colors" might not be needed or handled differently + + payload = { + "save": True, + "names": names, + "settings": payload_settings, + } + + # Update the settings object with the new pattern and current colors/brightness/delay + # It's important to save the state that *was active* when the pattern was set, + # or the state that should *persist* with the pattern. + self.settings["lights"][tab_name]["settings"]["pattern"] = pattern_name + # Ensure the saved colors are always the palette's current state + self.settings["lights"][tab_name]["settings"]["colors"] = current_tab_widget.colors_in_palette + self.settings.save() + + self.highlight_pattern_button(current_tab_widget, pattern_name) + self.update_ui_for_pattern(current_tab_widget, pattern_name) # Update visibility + + 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}") if __name__ == "__main__": diff --git a/networking.py b/networking.py index b1d76eb..c8e9e67 100644 --- a/networking.py +++ b/networking.py @@ -2,28 +2,52 @@ import asyncio import websockets import json -async def send_to_server(data): - """ - Send WebSocket data to the server. - """ - try: - # Connect to the WebSocket server - async with websockets.connect("ws://192.168.4.1:80/ws") as websocket: - # Serialize data to JSON and send it - await websocket.send(json.dumps(data)) - except (ConnectionError, websockets.exceptions.ConnectionClosed) as e: - print(f"Error sending to {e}") -if __name__ == "__main__": - # Define the data to be sent - data = { - "settings": { - "color": "#00ff00" - } - } +class WebSocketClient: + def __init__(self, uri): + self.uri = uri + self.websocket = None + self.is_connected = False - # Server details - server = ("192.168.4.1", 80) # Example WebSocket server port + async def connect(self): + """Establishes the WebSocket connection.""" + if self.is_connected and self.websocket: + print("Already connected.") + return - # Run the asynchronous function using asyncio.run - asyncio.run(send_to_server(data, server)) + try: + print(f"Connecting to {self.uri}...") + self.websocket = await websockets.connect(self.uri) + self.is_connected = True + print("WebSocket connected.") + except (ConnectionError, websockets.exceptions.ConnectionClosedOK) as e: + print(f"Error connecting: {e}") + self.is_connected = False + self.websocket = None + + async def send_data(self, data): + print(data) + """Sends data over the open WebSocket connection.""" + if not self.is_connected or not self.websocket: + print("WebSocket not connected. Attempting to reconnect...") + await self.connect() + if not self.is_connected: + print("Failed to reconnect. Cannot send data.") + return + + try: + await self.websocket.send(json.dumps(data)) + print(f"Sent: {data}") + except (ConnectionError, websockets.exceptions.ConnectionClosed) as e: + print(f"Error sending data: {e}") + self.is_connected = False + self.websocket = None # Reset connection on error + await self.connect() # Attempt to reconnect + + async def close(self): + """Closes the WebSocket connection.""" + if self.websocket and self.is_connected: + await self.websocket.close() + self.is_connected = False + self.websocket = None + print("WebSocket closed.") diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..2a3eac3 --- /dev/null +++ b/settings.json @@ -0,0 +1,26 @@ +{ + "lights": { + "light1": { + "names": [ + "test" + ], + "settings": { + "colors": [ + "#ff0000", + "#0000ff", + "#00ff00" + ], + "brightness": 255, + "pattern": "blink", + "delay": 40 + } + } + }, + "patterns": [ + "on", + "off", + "blink", + "rainbow_cycle", + "color_transition" + ] +} \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..571cd3b --- /dev/null +++ b/settings.py @@ -0,0 +1,27 @@ +import json + +class Settings(dict): + SETTINGS_FILE = "settings.json" + + def __init__(self): + super().__init__() + self.load() # Load settings from file during initialization + + def save(self): + try: + j = json.dumps(self, indent=4) + with open(self.SETTINGS_FILE, 'w') as file: + file.write(j) + print("Settings saved successfully.") + except Exception as e: + print(f"Error saving settings: {e}") + + def load(self): + try: + with open(self.SETTINGS_FILE, 'r') as file: + loaded_settings = json.load(file) + self.update(loaded_settings) + print("Settings loaded successfully.") + except Exception as e: + print(f"Error loading settings {e}") + self.save()