From 90be1984837c377c54faa4a9f20354784ccf4a00 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Thu, 8 Jan 2026 21:45:55 +1300 Subject: [PATCH] Add presets system and convert back to Flask - Convert from Microdot back to Flask - Add presets system with CRUD operations - Store presets in presets.json file - Replace patterns section with presets grid - Add preset editor with full configuration - Add collapse/expand functionality to left panel - Always show on/off presets in presets list - Highlight active preset matching current tab settings - Add 'Create from Current' button in preset editor --- Pipfile | 3 +- presets.json | 41 +++ profiles/default.json | 103 ++++++- run_web.py | 2 +- src/flask_app.py | 187 +++++++++++- src/microdot_app.py | 665 ++++++++++++++++++++++++++++++++++++++++++ static/app.js | 610 +++++++++++++++++++++++++++++++++++++- static/style.css | 43 +++ templates/index.html | 173 ++++++++--- tmp_explanation.txt | 2 + 10 files changed, 1758 insertions(+), 71 deletions(-) create mode 100644 presets.json create mode 100644 src/microdot_app.py diff --git a/Pipfile b/Pipfile index 50ea9d1..cc731e9 100644 --- a/Pipfile +++ b/Pipfile @@ -12,8 +12,7 @@ python-rtmidi = "*" pyaudio = "*" aubio = "*" websocket-client = "*" -flask = "*" -flask-cors = "*" +microdot = "*" [dev-packages] diff --git a/presets.json b/presets.json new file mode 100644 index 0000000..0557598 --- /dev/null +++ b/presets.json @@ -0,0 +1,41 @@ +{ + "blinker": { + "pattern": "blink", + "colors": [ + "#12b533" + ], + "brightness": 127, + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "circler": { + "pattern": "circle", + "colors": [ + "#9d3434", + "#cb5d5d" + ], + "brightness": 127, + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "pulser": { + "pattern": "pulse", + "colors": [ + "#9f1d1d", + "#176d2d", + "#50309c" + ], + "brightness": 127, + "delay": 300, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + } +} \ No newline at end of file diff --git a/profiles/default.json b/profiles/default.json index 04ac34f..8b8ff35 100644 --- a/profiles/default.json +++ b/profiles/default.json @@ -1,12 +1,16 @@ { "tab_password": "", + "color_palette": [ + "#c12525", + "#246dcc" + ], "lights": { "test": { "names": [ "test" ], "settings": { - "pattern": "transition", + "pattern": "pulse", "brightness": 127, "colors": [ "#000000" @@ -49,10 +53,94 @@ }, "transition": { "colors": [ - "#c12525", - "#246dcc" + "#000000" ], - "delay": 1321, + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "blink": { + "colors": [ + "#12b533" + ], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "circle": { + "colors": [ + "#9d3434", + "#cb5d5d" + ], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "pulse": { + "colors": [ + "#9f1d1d", + "#176d2d", + "#50309c" + ], + "delay": 300, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + } + } + } + }, + "test2": { + "names": [ + "test" + ], + "settings": { + "pattern": "pulse", + "brightness": 127, + "colors": [ + "#000000" + ], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10, + "patterns": { + "blink": { + "colors": [ + "#12b533" + ], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "circle": { + "colors": [ + "#9d3434", + "#cb5d5d" + ], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10 + }, + "pulse": { + "colors": [ + "#9f1d1d", + "#176d2d", + "#50309c" + ], + "delay": 300, "n1": 10, "n2": 10, "n3": 10, @@ -63,10 +151,7 @@ } }, "tab_order": [ - "test" - ], - "color_palette": [ - "#c12525", - "#246dcc" + "test", + "test2" ] } \ No newline at end of file diff --git a/run_web.py b/run_web.py index c543013..0fd5921 100755 --- a/run_web.py +++ b/run_web.py @@ -11,7 +11,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) from flask_app import app if __name__ == '__main__': - print("Starting Lighting Controller Web App...") + print("Starting Lighting Controller Web App with Flask...") print("Open http://localhost:5000 in your browser") app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/src/flask_app.py b/src/flask_app.py index 709a345..b94fa79 100644 --- a/src/flask_app.py +++ b/src/flask_app.py @@ -41,6 +41,9 @@ def load_current_profile(): settings.update(profile_data) settings["patterns"] = patterns_backup settings["current_profile"] = current_profile + # Ensure color_palette exists (default to empty array if not in profile) + if "color_palette" not in settings: + settings["color_palette"] = [] print(f"Loaded profile '{current_profile}' on startup.") except Exception as e: print(f"Error loading profile '{current_profile}': {e}") @@ -48,6 +51,29 @@ def load_current_profile(): # Load current profile when module is imported load_current_profile() +# Presets file path +PRESETS_FILE = "presets.json" + +def load_presets(): + """Load presets from presets.json file.""" + try: + if os.path.exists(PRESETS_FILE): + with open(PRESETS_FILE, 'r') as file: + return json.load(file) + return {} + except Exception as e: + print(f"Error loading presets: {e}") + return {} + +def save_presets(presets): + """Save presets to presets.json file.""" + try: + with open(PRESETS_FILE, 'w') as file: + json.dump(presets, file, indent=4) + print(f"Presets saved successfully.") + except Exception as e: + print(f"Error saving presets: {e}") + def delay_to_slider(delay_ms, min_delay=10, max_delay=10000): """Convert delay in ms to slider position (0-1000) using logarithmic scale.""" @@ -142,6 +168,9 @@ def save_current_profile(): profile_data = dict(settings) profile_data.pop("current_profile", None) profile_data.pop("patterns", None) # Patterns stay in settings.json + # Ensure color_palette is included if it exists + if "color_palette" not in profile_data: + profile_data["color_palette"] = settings.get("color_palette", []) with open(profile_path, 'w') as file: json.dump(profile_data, file, indent=4) @@ -206,7 +235,8 @@ def get_state(): "patterns": settings.get("patterns", {}), "tab_order": settings.get("tab_order", []), "current_profile": settings.get("current_profile", ""), - "color_palette": settings.get("color_palette", []) + "color_palette": settings.get("color_palette", []), + "presets": load_presets() }) @@ -619,6 +649,161 @@ def save_profile(profile_name): return jsonify({"success": True}) +@app.route('/api/presets', methods=['GET']) +def get_presets(): + """Get list of all presets.""" + presets = load_presets() + return jsonify({ + "presets": presets + }) + + +@app.route('/api/presets', methods=['POST']) +def create_preset(): + """Create a new preset.""" + data = request.json + preset_name = data.get("name") + + if not preset_name: + return jsonify({"error": "Missing name"}), 400 + + presets = load_presets() + + if preset_name in presets: + return jsonify({"error": f"Preset '{preset_name}' already exists"}), 400 + + # Validate required fields + required_fields = ["pattern", "colors", "brightness", "delay", "n1", "n2", "n3", "n4"] + for field in required_fields: + if field not in data: + return jsonify({"error": f"Missing required field: {field}"}), 400 + + preset = { + "pattern": data["pattern"], + "colors": data["colors"], + "brightness": int(data["brightness"]), + "delay": int(data["delay"]), + "n1": int(data["n1"]), + "n2": int(data["n2"]), + "n3": int(data["n3"]), + "n4": int(data["n4"]) + } + + presets[preset_name] = preset + save_presets(presets) + + return jsonify({"success": True, "preset": preset}) + + +@app.route('/api/presets/', methods=['PUT']) +def update_preset(preset_name): + """Update an existing preset.""" + data = request.json + new_name = data.get("name", preset_name) + + presets = load_presets() + + if preset_name not in presets: + return jsonify({"error": f"Preset '{preset_name}' not found"}), 404 + + # If renaming, check if new name exists + if new_name != preset_name: + if new_name in presets: + return jsonify({"error": f"Preset '{new_name}' already exists"}), 400 + # Rename preset + presets[new_name] = presets[preset_name] + del presets[preset_name] + preset_name = new_name + + # Update preset fields + preset = presets[preset_name] + if "pattern" in data: + preset["pattern"] = data["pattern"] + if "colors" in data: + preset["colors"] = data["colors"] + if "brightness" in data: + preset["brightness"] = int(data["brightness"]) + if "delay" in data: + preset["delay"] = int(data["delay"]) + for i in range(1, 5): + if f"n{i}" in data: + preset[f"n{i}"] = int(data[f"n{i}"]) + + save_presets(presets) + + return jsonify({"success": True, "preset": preset}) + + +@app.route('/api/presets/', methods=['DELETE']) +def delete_preset(preset_name): + """Delete a preset.""" + presets = load_presets() + + if preset_name not in presets: + return jsonify({"error": f"Preset '{preset_name}' not found"}), 404 + + del presets[preset_name] + save_presets(presets) + + return jsonify({"success": True}) + + +@app.route('/api/presets//apply', methods=['POST']) +def apply_preset(preset_name): + """Apply a preset to a tab.""" + data = request.json + tab_name = data.get("tab_name") + + if not tab_name: + return jsonify({"error": "Missing tab_name"}), 400 + + if tab_name not in settings.get("lights", {}): + return jsonify({"error": f"Tab '{tab_name}' not found"}), 404 + + presets = load_presets() + + if preset_name not in presets: + return jsonify({"error": f"Preset '{preset_name}' not found"}), 404 + + preset = presets[preset_name] + + # Apply preset to tab + light_settings = settings["lights"][tab_name]["settings"] + light_settings["pattern"] = preset["pattern"] + light_settings["brightness"] = preset["brightness"] + + # Save pattern-specific settings + save_pattern_settings( + tab_name, + preset["pattern"], + colors=preset["colors"], + delay=preset["delay"], + n_params={f"n{i}": preset[f"n{i}"] for i in range(1, 5)} + ) + + # Prepare payload for lighting controller + names = settings["lights"][tab_name]["names"] + payload = { + "save": True, + "names": names, + "settings": { + "pattern": preset["pattern"], + "brightness": preset["brightness"], + "delay": preset["delay"], + "colors": preset["colors"], + **{f"n{i}": preset[f"n{i}"] for i in range(1, 5)} + } + } + + # Send to lighting controller + run_async(send_to_lighting_controller(payload)) + + settings.save() + save_current_profile() + + return jsonify({"success": True}) + + def init_websocket(): """Initialize WebSocket connection in background.""" global websocket_client diff --git a/src/microdot_app.py b/src/microdot_app.py new file mode 100644 index 0000000..0e35296 --- /dev/null +++ b/src/microdot_app.py @@ -0,0 +1,665 @@ +""" +Microdot web application for the lighting controller. +Provides REST API and serves the web UI. +""" +import asyncio +import json +import os +import math +from microdot import Microdot, Request, Response +from networking import WebSocketClient +import color_utils +from settings import Settings + +app = Microdot() + +# CORS middleware +@app.after_request() +def cors_handler(req, res): + """Add CORS headers to all responses.""" + res.headers['Access-Control-Allow-Origin'] = '*' + res.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + res.headers['Access-Control-Allow-Headers'] = 'Content-Type' + return res + +@app.route('/', methods=['OPTIONS']) +def options_handler(req, path): + """Handle OPTIONS requests for CORS.""" + return Response('', status_code=204, headers={ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }) + +# Global settings and WebSocket client +settings = Settings() +websocket_client = None +websocket_uri = "ws://192.168.4.1:80/ws" + +# Load current profile on startup +def load_current_profile(): + """Load the current profile if one is set.""" + current_profile = settings.get("current_profile") + if current_profile: + profile_path = os.path.join("profiles", f"{current_profile}.json") + if os.path.exists(profile_path): + try: + with open(profile_path, 'r') as file: + profile_data = json.load(file) + + # Update settings with profile data + profile_data.pop("current_profile", None) + patterns_backup = settings.get("patterns", {}) + tab_password_backup = settings.get("tab_password", "") + + settings.update(profile_data) + settings["patterns"] = patterns_backup + settings["current_profile"] = current_profile + # Ensure color_palette exists (default to empty array if not in profile) + if "color_palette" not in settings: + settings["color_palette"] = [] + print(f"Loaded profile '{current_profile}' on startup.") + except Exception as e: + print(f"Error loading profile '{current_profile}': {e}") + +# Load current profile when module is imported +load_current_profile() + + +def delay_to_slider(delay_ms, min_delay=10, max_delay=10000): + """Convert delay in ms to slider position (0-1000) using logarithmic scale.""" + if delay_ms <= min_delay: + return 0 + if delay_ms >= max_delay: + return 1000 + if min_delay == max_delay: + return 0 + return 1000 * math.log(delay_ms / min_delay) / math.log(max_delay / min_delay) + + +def slider_to_delay(slider_value, min_delay=10, max_delay=10000): + """Convert slider position (0-1000) to delay in ms using logarithmic scale.""" + if slider_value <= 0: + return min_delay + if slider_value >= 1000: + return max_delay + if min_delay == max_delay: + return min_delay + return int(min_delay * ((max_delay / min_delay) ** (slider_value / 1000))) + + +def get_pattern_settings(tab_name, pattern_name): + """Get pattern-specific settings.""" + light_settings = settings["lights"][tab_name]["settings"] + if "patterns" not in light_settings: + light_settings["patterns"] = {} + if pattern_name not in light_settings["patterns"]: + light_settings["patterns"][pattern_name] = {} + + pattern_settings = light_settings["patterns"][pattern_name] + # Fall back to global settings if pattern-specific settings don't exist + global_colors = light_settings.get("colors", ["#000000"]) + return { + "colors": pattern_settings.get("colors", global_colors), + "delay": pattern_settings.get("delay", light_settings.get("delay", 100)), + "n1": pattern_settings.get("n1", light_settings.get("n1", 10)), + "n2": pattern_settings.get("n2", light_settings.get("n2", 10)), + "n3": pattern_settings.get("n3", light_settings.get("n3", 10)), + "n4": pattern_settings.get("n4", light_settings.get("n4", 10)), + } + + +def save_pattern_settings(tab_name, pattern_name, colors=None, delay=None, n_params=None): + """Save pattern-specific settings.""" + light_settings = settings["lights"][tab_name]["settings"] + if "patterns" not in light_settings: + light_settings["patterns"] = {} + if pattern_name not in light_settings["patterns"]: + light_settings["patterns"][pattern_name] = {} + + pattern_settings = light_settings["patterns"][pattern_name] + if colors is not None: + pattern_settings["colors"] = colors + if delay is not None: + pattern_settings["delay"] = delay + if n_params is not None: + for i in range(1, 5): + if f"n{i}" in n_params: + pattern_settings[f"n{i}"] = n_params[f"n{i}"] + + +def save_current_profile(): + """Save current settings to the active profile file.""" + current_profile = settings.get("current_profile") + + # If no profile is set, create/use a default profile + if not current_profile: + current_profile = "default" + settings["current_profile"] = current_profile + # Save current_profile to settings.json + settings.save() + + try: + profiles_dir = "profiles" + os.makedirs(profiles_dir, exist_ok=True) + profile_path = os.path.join(profiles_dir, f"{current_profile}.json") + + # Get current tab order + tab_order = settings.get("tab_order", []) + if "lights" in settings: + # Ensure all current tabs are in the order + current_tabs = set(settings["lights"].keys()) + order_tabs = set(tab_order) + for tab in current_tabs: + if tab not in order_tabs: + tab_order.append(tab) + settings["tab_order"] = tab_order + + # Save to profile file (exclude current_profile from profile) + profile_data = dict(settings) + profile_data.pop("current_profile", None) + profile_data.pop("patterns", None) # Patterns stay in settings.json + # Ensure color_palette is included if it exists + if "color_palette" not in profile_data: + profile_data["color_palette"] = settings.get("color_palette", []) + + with open(profile_path, 'w') as file: + json.dump(profile_data, file, indent=4) + print(f"Profile '{current_profile}' saved successfully.") + except Exception as e: + print(f"Error saving profile: {e}") + + +async def send_to_lighting_controller(payload): + """Send data to the lighting controller via WebSocket.""" + global websocket_client + if websocket_client is None: + websocket_client = WebSocketClient(websocket_uri) + await websocket_client.connect() + + if not websocket_client.is_connected: + await websocket_client.connect() + + if websocket_client.is_connected: + await websocket_client.send_data(payload) + + +def run_async(coro): + """Run async function in sync context.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +@app.route('/') +def index(req): + """Serve the main web UI.""" + return Response.send_file('templates/index.html', base_path='.') + + +@app.route('/api/state', methods=['GET']) +def get_state(req): + """Get the current state of all lights.""" + # Ensure a profile is set if we have lights but no profile + if settings.get("lights") and not settings.get("current_profile"): + current_profile = "default" + settings["current_profile"] = current_profile + settings.save() + # Create default profile file if it doesn't exist + profiles_dir = "profiles" + os.makedirs(profiles_dir, exist_ok=True) + profile_path = os.path.join(profiles_dir, f"{current_profile}.json") + if not os.path.exists(profile_path): + profile_data = { + "lights": settings.get("lights", {}), + "tab_order": settings.get("tab_order", []), + "tab_password": settings.get("tab_password", "") + } + with open(profile_path, 'w') as file: + json.dump(profile_data, file, indent=4) + + return { + "lights": settings.get("lights", {}), + "patterns": settings.get("patterns", {}), + "tab_order": settings.get("tab_order", []), + "current_profile": settings.get("current_profile", ""), + "color_palette": settings.get("color_palette", []) + } + + +@app.route('/api/pattern', methods=['POST']) +def set_pattern(req): + """Set the pattern for a light group.""" + data = req.json + tab_name = data.get("tab_name") + pattern_name = data.get("pattern") + + if not tab_name or not pattern_name: + return {"error": "Missing tab_name or pattern"}, 400 + + if tab_name not in settings.get("lights", {}): + return {"error": f"Tab '{tab_name}' not found"}, 404 + + # Save current pattern's settings before switching + old_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on") + # Get current delay (would need to be passed from frontend) + current_delay = data.get("delay", 100) + current_n_params = { + f"n{i}": data.get(f"n{i}", 10) for i in range(1, 5) + } + save_pattern_settings( + tab_name, + old_pattern, + colors=data.get("colors", ["#000000"]), + delay=current_delay, + n_params=current_n_params + ) + + # Load new pattern's settings + new_pattern_settings = get_pattern_settings(tab_name, pattern_name) + + # Update settings + settings["lights"][tab_name]["settings"]["pattern"] = pattern_name + + # Prepare payload for lighting controller + names = settings["lights"][tab_name]["names"] + payload = { + "save": True, + "names": names, + "settings": { + "pattern": pattern_name, + "brightness": settings["lights"][tab_name]["settings"].get("brightness", 127), + "delay": new_pattern_settings["delay"], + "colors": new_pattern_settings["colors"], + **{f"n{i}": new_pattern_settings[f"n{i}"] for i in range(1, 5)} + } + } + + # Send to lighting controller + run_async(send_to_lighting_controller(payload)) + + settings.save() + save_current_profile() + + return { + "success": True, + "pattern": pattern_name, + "settings": new_pattern_settings + } + + +@app.route('/api/parameters', methods=['POST']) +def set_parameters(req): + """Update parameters (RGB, brightness, delay, n params) for a light group.""" + data = req.json + tab_name = data.get("tab_name") + + if not tab_name: + return {"error": "Missing tab_name"}, 400 + + if tab_name not in settings.get("lights", {}): + return {"error": f"Tab '{tab_name}' not found"}, 404 + + current_pattern = settings["lights"][tab_name]["settings"].get("pattern", "on") + pattern_config = settings.get("patterns", {}).get(current_pattern, {}) + min_delay = pattern_config.get("min_delay", 10) + max_delay = pattern_config.get("max_delay", 10000) + + # Build settings payload + payload_settings = {} + + # Handle RGB colors + if "red" in data or "green" in data or "blue" in data: + r = data.get("red", 0) + g = data.get("green", 0) + b = data.get("blue", 0) + hex_color = f"#{r:02x}{g:02x}{b:02x}" + + # Update color in palette + pattern_settings = get_pattern_settings(tab_name, current_pattern) + colors = pattern_settings["colors"].copy() + selected_index = data.get("color_index", 0) + if 0 <= selected_index < len(colors): + colors[selected_index] = hex_color + else: + # If index is out of range, append the color + colors.append(hex_color) + + # Save pattern settings to persist the color change + save_pattern_settings(tab_name, current_pattern, colors=colors) + payload_settings["colors"] = colors + + # Handle brightness + if "brightness" in data: + brightness = int(data["brightness"]) + settings["lights"][tab_name]["settings"]["brightness"] = brightness + payload_settings["brightness"] = brightness + + # Handle delay + if "delay_slider" in data: + slider_value = int(data["delay_slider"]) + delay = slider_to_delay(slider_value, min_delay, max_delay) + save_pattern_settings(tab_name, current_pattern, delay=delay) + payload_settings["delay"] = delay + + # Handle n parameters + n_params = {} + for i in range(1, 5): + if f"n{i}" in data: + n_params[f"n{i}"] = int(data[f"n{i}"]) + + if n_params: + save_pattern_settings(tab_name, current_pattern, n_params=n_params) + payload_settings.update(n_params) + + # Send to lighting controller + if payload_settings: + names = settings["lights"][tab_name]["names"] + payload = { + "save": True, + "names": names, + "settings": payload_settings + } + run_async(send_to_lighting_controller(payload)) + + # Save to settings.json (for patterns) and to profile file (for lights data) + settings.save() + save_current_profile() + + return {"success": True} + + +@app.route('/api/tabs', methods=['GET']) +def get_tabs(req): + """Get list of tabs.""" + return { + "tabs": settings.get("tab_order", []), + "lights": settings.get("lights", {}) + } + + +@app.route('/api/tabs', methods=['POST']) +def create_tab(req): + """Create a new tab.""" + data = req.json + tab_name = data.get("name") + ids = data.get("ids", ["1"]) + + if not tab_name: + return {"error": "Missing name"}, 400 + + if tab_name in settings.get("lights", {}): + return {"error": f"Tab '{tab_name}' already exists"}, 400 + + settings.setdefault("lights", {})[tab_name] = { + "names": ids if isinstance(ids, list) else [ids], + "settings": { + "pattern": "on", + "brightness": 127, + "colors": ["#000000"], + "delay": 100, + "n1": 10, + "n2": 10, + "n3": 10, + "n4": 10, + "patterns": {} + } + } + + if "tab_order" not in settings: + settings["tab_order"] = [] + settings["tab_order"].append(tab_name) + settings.save() + save_current_profile() + + return {"success": True, "tab_name": tab_name} + + +@app.route('/api/tabs/', methods=['PUT']) +def update_tab(req, tab_name): + """Update a tab.""" + data = req.json + new_name = data.get("name", tab_name) + ids = data.get("ids") + + if tab_name not in settings.get("lights", {}): + return {"error": f"Tab '{tab_name}' not found"}, 404 + + if new_name != tab_name: + if new_name in settings.get("lights", {}): + return {"error": f"Tab '{new_name}' already exists"}, 400 + + # Rename tab + settings["lights"][new_name] = settings["lights"][tab_name] + del settings["lights"][tab_name] + + # Update tab order + if "tab_order" in settings and tab_name in settings["tab_order"]: + index = settings["tab_order"].index(tab_name) + settings["tab_order"][index] = new_name + + tab_name = new_name + + if ids is not None: + settings["lights"][tab_name]["names"] = ids if isinstance(ids, list) else [ids] + + settings.save() + save_current_profile() + + return {"success": True, "tab_name": tab_name} + + +@app.route('/api/tabs/', methods=['DELETE']) +def delete_tab(req, tab_name): + """Delete a tab.""" + if tab_name not in settings.get("lights", {}): + return {"error": f"Tab '{tab_name}' not found"}, 404 + + del settings["lights"][tab_name] + + if "tab_order" in settings and tab_name in settings["tab_order"]: + settings["tab_order"].remove(tab_name) + + settings.save() + save_current_profile() + + return {"success": True} + + +@app.route('/api/profiles', methods=['GET']) +def get_profiles(req): + """Get list of profiles.""" + profiles_dir = "profiles" + profiles = [] + if os.path.exists(profiles_dir): + for filename in os.listdir(profiles_dir): + if filename.endswith('.json'): + profiles.append(filename[:-5]) + profiles.sort() + + return { + "profiles": profiles, + "current_profile": settings.get("current_profile", ""), + "color_palette": settings.get("color_palette", []) + } + + +@app.route('/api/profiles', methods=['POST']) +def create_profile(req): + """Create a new profile.""" + data = req.json + profile_name = data.get("name") + + if not profile_name: + return {"error": "Missing name"}, 400 + + profiles_dir = "profiles" + os.makedirs(profiles_dir, exist_ok=True) + profile_path = os.path.join(profiles_dir, f"{profile_name}.json") + + if os.path.exists(profile_path): + return {"error": f"Profile '{profile_name}' already exists"}, 400 + + empty_profile = { + "lights": {}, + "tab_password": "", + "tab_order": [], + "color_palette": [] + } + + with open(profile_path, 'w') as file: + json.dump(empty_profile, file, indent=4) + + return {"success": True, "profile_name": profile_name} + + +@app.route('/api/profiles/', methods=['DELETE']) +def delete_profile(req, profile_name): + """Delete a profile.""" + profiles_dir = "profiles" + profile_path = os.path.join(profiles_dir, f"{profile_name}.json") + + if not os.path.exists(profile_path): + return {"error": f"Profile '{profile_name}' not found"}, 404 + + # Prevent deleting the only existing profile to avoid leaving the app with no profiles + existing_profiles = [ + f[:-5] for f in os.listdir(profiles_dir) if f.endswith('.json') + ] if os.path.exists(profiles_dir) else [] + if len(existing_profiles) <= 1: + return {"error": "Cannot delete the only existing profile"}, 400 + + # If deleting the current profile, clear current_profile and related state + if settings.get("current_profile") == profile_name: + settings["current_profile"] = "" + settings["lights"] = {} + settings["tab_order"] = [] + settings["color_palette"] = [] + # Persist to settings.json + settings_to_save = { + "tab_password": settings.get("tab_password", ""), + "current_profile": "", + "patterns": settings.get("patterns", {}) + } + with open("settings.json", 'w') as f: + json.dump(settings_to_save, f, indent=4) + + # Remove the profile file + os.remove(profile_path) + + return {"success": True} + + +@app.route('/api/profiles/', methods=['POST']) +def load_profile(req, profile_name): + """Load a profile.""" + profile_path = os.path.join("profiles", f"{profile_name}.json") + + if not os.path.exists(profile_path): + return {"error": f"Profile '{profile_name}' not found"}, 404 + + with open(profile_path, 'r') as file: + profile_data = json.load(file) + + # Update settings with profile data + profile_data.pop("current_profile", None) + patterns_backup = settings.get("patterns", {}) + tab_password_backup = settings.get("tab_password", "") + + settings.update(profile_data) + settings["patterns"] = patterns_backup + settings["current_profile"] = profile_name + # Ensure color_palette exists (default to empty array if not in profile) + if "color_palette" not in settings: + settings["color_palette"] = [] + + settings_to_save = { + "tab_password": tab_password_backup, + "current_profile": profile_name, + "patterns": patterns_backup + } + with open("settings.json", 'w') as f: + json.dump(settings_to_save, f, indent=4) + + return {"success": True} + +@app.route('/api/profiles//palette', methods=['GET']) +def get_profile_palette(req, profile_name): + """Get the color palette for a profile.""" + profile_path = os.path.join("profiles", f"{profile_name}.json") + + if not os.path.exists(profile_path): + return {"error": f"Profile '{profile_name}' not found"}, 404 + + with open(profile_path, 'r') as file: + profile_data = json.load(file) + + palette = profile_data.get("color_palette", []) + return {"color_palette": palette} + +@app.route('/api/profiles//palette', methods=['POST']) +def update_profile_palette(req, profile_name): + """Update the color palette for a profile.""" + data = req.json + color_palette = data.get("color_palette", []) + + profile_path = os.path.join("profiles", f"{profile_name}.json") + + if not os.path.exists(profile_path): + return {"error": f"Profile '{profile_name}' not found"}, 404 + + with open(profile_path, 'r') as file: + profile_data = json.load(file) + + profile_data["color_palette"] = color_palette + + with open(profile_path, 'w') as file: + json.dump(profile_data, file, indent=4) + + # Update current settings if this is the active profile + if settings.get("current_profile") == profile_name: + settings["color_palette"] = color_palette + + return {"success": True, "color_palette": color_palette} + +@app.route('/api/profiles//save', methods=['POST']) +def save_profile(req, profile_name): + """Save current state to a profile.""" + # Save current state to the specified profile + save_current_profile() + + # If saving to a different profile, switch to it + if profile_name != settings.get("current_profile"): + settings["current_profile"] = profile_name + settings.save() + save_current_profile() + + return {"success": True} + + +# Serve static files +@app.route('/static/') +def serve_static(req, path): + """Serve static files.""" + return Response.send_file(f'static/{path}', base_path='.') + + +def init_websocket(): + """Initialize WebSocket connection in background.""" + global websocket_client + if websocket_client is None: + websocket_client = WebSocketClient(websocket_uri) + run_async(websocket_client.connect()) + + +if __name__ == '__main__': + # Initialize WebSocket connection + init_websocket() + + print("Starting Lighting Controller Web App with Microdot...") + print("Open http://localhost:5000 in your browser") + app.run(host='0.0.0.0', port=5000, debug=True) + diff --git a/static/app.js b/static/app.js index 332b4da..7b92206 100644 --- a/static/app.js +++ b/static/app.js @@ -5,7 +5,8 @@ class LightingController { this.state = { lights: {}, patterns: {}, - tab_order: [] + tab_order: [], + presets: {} }; this.selectedColorIndex = 0; this.updateTimeouts = {}; @@ -28,6 +29,10 @@ class LightingController { const response = await fetch('/api/state'); const data = await response.json(); this.state = data; + // Ensure presets is always an object + if (!this.state.presets) { + this.state.presets = {}; + } // Update current profile display this.updateCurrentProfileDisplay(); // Update current profile display if profiles modal is open @@ -54,6 +59,7 @@ class LightingController { document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal()); document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab()); document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette()); + document.getElementById('presets-btn').addEventListener('click', () => this.showPresets()); document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles()); // Modal actions @@ -63,11 +69,22 @@ class LightingController { document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal')); document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal')); document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal')); + document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal')); + document.getElementById('create-preset-btn').addEventListener('click', () => this.showPresetEditor()); + document.getElementById('preset-editor-save-btn').addEventListener('click', () => this.savePreset()); + document.getElementById('preset-editor-cancel-btn').addEventListener('click', () => this.hideModal('preset-editor-modal')); + document.getElementById('preset-add-color-btn').addEventListener('click', () => this.addPresetColor()); + document.getElementById('preset-remove-color-btn').addEventListener('click', () => this.removePresetColor()); + document.getElementById('preset-editor-from-current-btn').addEventListener('click', () => this.loadCurrentTabToPresetEditor()); + document.getElementById('preset-brightness-slider').addEventListener('input', (e) => { + document.getElementById('preset-brightness-value').textContent = e.target.value; + }); document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal()); document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette()); document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile()); document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor()); document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal()); + document.getElementById('toggle-left-panel').addEventListener('click', () => this.toggleLeftPanel()); // Enter key for new profile name document.getElementById('new-profile-name').addEventListener('keypress', (e) => { @@ -107,6 +124,12 @@ class LightingController { document.getElementById('profiles-modal').addEventListener('click', (e) => { if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal'); }); + document.getElementById('presets-modal').addEventListener('click', (e) => { + if (e.target.id === 'presets-modal') this.hideModal('presets-modal'); + }); + document.getElementById('preset-editor-modal').addEventListener('click', (e) => { + if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal'); + }); } renderTabs() { @@ -125,6 +148,12 @@ class LightingController { }); } + toggleLeftPanel() { + const leftPanel = document.querySelector('.left-panel'); + if (!leftPanel) return; + leftPanel.classList.toggle('collapsed'); + } + async selectTab(tabName) { if (!this.state.lights[tabName]) return; @@ -169,25 +198,228 @@ class LightingController { document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10; } - // Render patterns - this.renderPatterns(tabName, pattern); + // Render presets + this.renderPresets(tabName); } - renderPatterns(tabName, activePattern) { - const patternsList = document.getElementById('patterns-list'); - patternsList.innerHTML = ''; + renderPresets(tabName) { + const presetsList = document.getElementById('presets-list-tab'); + presetsList.innerHTML = ''; - Object.keys(this.state.patterns).forEach(patternName => { - const button = document.createElement('button'); - button.className = 'pattern-button'; - button.textContent = patternName; - if (patternName === activePattern) { - button.classList.add('active'); + const presets = this.state.presets || {}; + const presetNames = Object.keys(presets); + + // Get current tab's settings for comparison + const currentSettings = this.getCurrentTabSettings(tabName); + + // Always include "on" and "off" presets + const defaultPresets = { + 'on': { + pattern: 'on', + colors: ['#FFFFFF'], + brightness: 255, + delay: 100, + n1: 10, + n2: 10, + n3: 10, + n4: 10 + }, + 'off': { + pattern: 'off', + colors: ['#000000'], + brightness: 0, + delay: 100, + n1: 10, + n2: 10, + n3: 10, + n4: 10 } - button.addEventListener('click', () => this.setPattern(tabName, patternName)); - patternsList.appendChild(button); + }; + + // Create a combined list with default presets first, then user presets + const allPresets = { ...defaultPresets, ...presets }; + const allPresetNames = ['on', 'off', ...presetNames.filter(name => name !== 'on' && name !== 'off')]; + + allPresetNames.forEach(presetName => { + const preset = allPresets[presetName]; + const presetButton = document.createElement('button'); + presetButton.className = 'pattern-button'; + + // Check if this preset matches the current tab's settings + const isActive = this.presetMatchesSettings(preset, currentSettings); + if (isActive) { + presetButton.classList.add('active'); + } + + // Mark default presets (on/off) with a special indicator + const isDefault = presetName === 'on' || presetName === 'off'; + if (isDefault) { + presetButton.classList.add('default-preset'); + } + + // Create preset info display + const presetInfo = document.createElement('div'); + presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;'; + + const presetNameLabel = document.createElement('span'); + presetNameLabel.textContent = presetName; + presetNameLabel.style.fontWeight = 'bold'; + presetNameLabel.style.marginBottom = '0.25rem'; + + const presetDetails = document.createElement('span'); + presetDetails.style.fontSize = '0.85em'; + presetDetails.style.color = '#aaa'; + presetDetails.textContent = `${preset.pattern} • ${preset.colors.length} color${preset.colors.length !== 1 ? 's' : ''}`; + + presetInfo.appendChild(presetNameLabel); + presetInfo.appendChild(presetDetails); + presetButton.appendChild(presetInfo); + + presetButton.addEventListener('click', () => { + if (isDefault && !presets[presetName]) { + // Apply default preset directly + this.applyDefaultPreset(tabName, presetName, preset); + } else { + // Apply regular preset + this.applyPresetToTab(tabName, presetName); + } + }); + presetsList.appendChild(presetButton); }); } + + async applyDefaultPreset(tabName, presetName, preset) { + // Apply default preset by setting pattern and parameters directly + try { + // First set the pattern + const patternResponse = await fetch('/api/pattern', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tab_name: tabName, + pattern: preset.pattern, + delay: preset.delay, + colors: preset.colors, + n1: preset.n1, + n2: preset.n2, + n3: preset.n3, + n4: preset.n4 + }) + }); + + if (patternResponse.ok) { + // Then update brightness + await fetch('/api/parameters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tab_name: tabName, + brightness: preset.brightness + }) + }); + + // Reload state and tab content + await this.loadState(); + await this.loadTabContent(tabName); + } else { + const error = await patternResponse.json(); + alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Failed to apply default preset:', error); + alert('Failed to apply preset'); + } + } + + getCurrentTabSettings(tabName) { + if (!this.state.lights[tabName]) { + return null; + } + + const light = this.state.lights[tabName]; + const settings = light.settings; + const pattern = settings.pattern || 'on'; + const patternSettings = this.getPatternSettings(tabName, pattern); + + return { + pattern: pattern, + brightness: settings.brightness || 127, + delay: patternSettings.delay || 100, + colors: patternSettings.colors || ['#000000'], + n1: patternSettings.n1 || 10, + n2: patternSettings.n2 || 10, + n3: patternSettings.n3 || 10, + n4: patternSettings.n4 || 10 + }; + } + + presetMatchesSettings(preset, currentSettings) { + if (!currentSettings) { + return false; + } + + // Compare all settings + if (preset.pattern !== currentSettings.pattern) { + return false; + } + + if (preset.brightness !== currentSettings.brightness) { + return false; + } + + if (preset.delay !== currentSettings.delay) { + return false; + } + + // Compare n values + for (let i = 1; i <= 4; i++) { + if (preset[`n${i}`] !== currentSettings[`n${i}`]) { + return false; + } + } + + // Compare colors (order matters for presets) + if (preset.colors.length !== currentSettings.colors.length) { + return false; + } + + for (let i = 0; i < preset.colors.length; i++) { + // Normalize color format (uppercase, no spaces) + const presetColor = preset.colors[i].toUpperCase().trim(); + const currentColor = currentSettings.colors[i].toUpperCase().trim(); + if (presetColor !== currentColor) { + return false; + } + } + + return true; + } + + async applyPresetToTab(tabName, presetName) { + try { + const response = await fetch(`/api/presets/${presetName}/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + tab_name: tabName + }) + }); + + if (response.ok) { + // Reload state and tab content to reflect changes + await this.loadState(); + await this.loadTabContent(tabName); + } else { + const error = await response.json(); + alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Failed to apply preset:', error); + alert('Failed to apply preset'); + } + } renderColorPalette(tabName, colors) { const palette = document.getElementById('color-palette'); @@ -1056,6 +1288,356 @@ class LightingController { } } + async showPresets() { + const modal = document.getElementById('presets-modal'); + modal.classList.add('active'); + await this.loadPresets(); + } + + async loadPresets() { + try { + const response = await fetch('/api/presets'); + const data = await response.json(); + + this.state.presets = data.presets || {}; + + const presetsList = document.getElementById('presets-list'); + presetsList.innerHTML = ''; + + const presetNames = Object.keys(this.state.presets); + if (presetNames.length === 0) { + presetsList.innerHTML = '

No presets found. Create one to get started.

'; + } else { + presetNames.forEach(presetName => { + const preset = this.state.presets[presetName]; + const presetItem = document.createElement('div'); + presetItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;'; + + const presetInfo = document.createElement('div'); + presetInfo.style.cssText = 'display: flex; flex-direction: column; gap: 0.25rem; flex: 1;'; + + const presetNameLabel = document.createElement('span'); + presetNameLabel.textContent = presetName; + presetNameLabel.style.fontWeight = 'bold'; + + const presetDetails = document.createElement('span'); + presetDetails.style.fontSize = '0.9em'; + presetDetails.style.color = '#aaa'; + presetDetails.textContent = `Pattern: ${preset.pattern} | Brightness: ${preset.brightness} | Delay: ${preset.delay}ms | Colors: ${preset.colors.length}`; + + presetInfo.appendChild(presetNameLabel); + presetInfo.appendChild(presetDetails); + + const actionsContainer = document.createElement('div'); + actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;'; + + const applyButton = document.createElement('button'); + applyButton.className = 'btn btn-small btn-primary'; + applyButton.textContent = 'Apply'; + applyButton.addEventListener('click', () => this.applyPreset(presetName)); + + const editButton = document.createElement('button'); + editButton.className = 'btn btn-small'; + editButton.textContent = 'Edit'; + editButton.addEventListener('click', () => this.showPresetEditor(presetName)); + + const deleteButton = document.createElement('button'); + deleteButton.className = 'btn btn-small btn-danger'; + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', () => this.deletePreset(presetName)); + + actionsContainer.appendChild(applyButton); + actionsContainer.appendChild(editButton); + actionsContainer.appendChild(deleteButton); + + presetItem.appendChild(presetInfo); + presetItem.appendChild(actionsContainer); + presetsList.appendChild(presetItem); + }); + } + } catch (error) { + console.error('Failed to load presets:', error); + alert('Failed to load presets'); + } + } + + async applyPreset(presetName) { + if (!this.currentTab) { + alert('Please select a tab first'); + return; + } + + try { + const response = await fetch(`/api/presets/${presetName}/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + tab_name: this.currentTab + }) + }); + + if (response.ok) { + await this.loadState(); + await this.loadTabContent(this.currentTab); + this.hideModal('presets-modal'); + } else { + const error = await response.json(); + alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Failed to apply preset:', error); + alert('Failed to apply preset'); + } + } + + async deletePreset(presetName) { + if (!confirm(`Delete preset '${presetName}'? This cannot be undone.`)) { + return; + } + + try { + const response = await fetch(`/api/presets/${presetName}`, { + method: 'DELETE' + }); + + if (response.ok) { + await this.loadPresets(); + } else { + const error = await response.json(); + alert(`Failed to delete preset: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Failed to delete preset:', error); + alert('Failed to delete preset'); + } + } + + showPresetEditor(presetName = null) { + const modal = document.getElementById('preset-editor-modal'); + const title = document.getElementById('preset-editor-title'); + const nameInput = document.getElementById('preset-name-input'); + const patternSelect = document.getElementById('preset-pattern-select'); + const brightnessSlider = document.getElementById('preset-brightness-slider'); + const brightnessValue = document.getElementById('preset-brightness-value'); + const delayInput = document.getElementById('preset-delay-input'); + const n1Input = document.getElementById('preset-n1-input'); + const n2Input = document.getElementById('preset-n2-input'); + const n3Input = document.getElementById('preset-n3-input'); + const n4Input = document.getElementById('preset-n4-input'); + + // Store editing preset name + this.editingPresetName = presetName; + + // Populate pattern select + patternSelect.innerHTML = ''; + const patterns = Object.keys(this.state.patterns || {}); + patterns.forEach(pattern => { + const option = document.createElement('option'); + option.value = pattern; + option.textContent = pattern; + patternSelect.appendChild(option); + }); + + if (presetName && this.state.presets[presetName]) { + // Editing existing preset + title.textContent = 'Edit Preset'; + const preset = this.state.presets[presetName]; + nameInput.value = presetName; + nameInput.disabled = false; + patternSelect.value = preset.pattern; + brightnessSlider.value = preset.brightness; + brightnessValue.textContent = preset.brightness; + delayInput.value = preset.delay; + n1Input.value = preset.n1; + n2Input.value = preset.n2; + n3Input.value = preset.n3; + n4Input.value = preset.n4; + this.renderPresetColors(preset.colors); + } else { + // Creating new preset + title.textContent = 'Create Preset'; + nameInput.value = ''; + nameInput.disabled = false; + patternSelect.value = patterns[0] || 'on'; + brightnessSlider.value = 127; + brightnessValue.textContent = 127; + delayInput.value = 100; + n1Input.value = 10; + n2Input.value = 10; + n3Input.value = 10; + n4Input.value = 10; + this.renderPresetColors(['#000000']); + } + + this.selectedPresetColorIndex = 0; + modal.classList.add('active'); + } + + renderPresetColors(colors) { + const container = document.getElementById('preset-colors-container'); + container.innerHTML = ''; + + colors.forEach((color, index) => { + const colorWrapper = document.createElement('div'); + colorWrapper.style.cssText = 'position: relative;'; + + const colorSwatch = document.createElement('div'); + colorSwatch.style.cssText = `width: 50px; height: 50px; background-color: ${color}; border: 2px solid ${index === this.selectedPresetColorIndex ? '#FFD700' : '#4a4a4a'}; border-radius: 4px; cursor: pointer; position: relative;`; + colorSwatch.dataset.index = index; + colorSwatch.addEventListener('click', () => { + this.selectedPresetColorIndex = index; + this.renderPresetColors(colors); + }); + + const colorPicker = document.createElement('input'); + colorPicker.type = 'color'; + colorPicker.value = color; + colorPicker.style.cssText = 'position: absolute; top: 0; left: 0; width: 50px; height: 50px; opacity: 0; cursor: pointer;'; + colorPicker.addEventListener('change', (e) => { + colors[index] = e.target.value; + this.renderPresetColors(colors); + }); + + colorWrapper.appendChild(colorSwatch); + colorWrapper.appendChild(colorPicker); + container.appendChild(colorWrapper); + }); + + this.presetColors = colors; + } + + addPresetColor() { + const colorInput = document.getElementById('preset-new-color'); + const color = colorInput.value; + + if (!this.presetColors) { + this.presetColors = []; + } + + this.presetColors.push(color); + this.selectedPresetColorIndex = this.presetColors.length - 1; + this.renderPresetColors(this.presetColors); + } + + removePresetColor() { + if (!this.presetColors || this.presetColors.length === 0) { + return; + } + + if (this.selectedPresetColorIndex >= 0 && this.selectedPresetColorIndex < this.presetColors.length) { + this.presetColors.splice(this.selectedPresetColorIndex, 1); + if (this.selectedPresetColorIndex >= this.presetColors.length) { + this.selectedPresetColorIndex = Math.max(0, this.presetColors.length - 1); + } + this.renderPresetColors(this.presetColors); + } + } + + loadCurrentTabToPresetEditor() { + if (!this.currentTab || !this.state.lights[this.currentTab]) { + alert('Please select a tab first'); + return; + } + + const light = this.state.lights[this.currentTab]; + const settings = light.settings; + const pattern = settings.pattern || 'on'; + + // Get pattern-specific settings + const patternSettings = this.getPatternSettings(this.currentTab, pattern); + + const patternSelect = document.getElementById('preset-pattern-select'); + const brightnessSlider = document.getElementById('preset-brightness-slider'); + const brightnessValue = document.getElementById('preset-brightness-value'); + const delayInput = document.getElementById('preset-delay-input'); + const n1Input = document.getElementById('preset-n1-input'); + const n2Input = document.getElementById('preset-n2-input'); + const n3Input = document.getElementById('preset-n3-input'); + const n4Input = document.getElementById('preset-n4-input'); + + patternSelect.value = pattern; + brightnessSlider.value = settings.brightness || 127; + brightnessValue.textContent = settings.brightness || 127; + delayInput.value = patternSettings.delay || 100; + n1Input.value = patternSettings.n1 || 10; + n2Input.value = patternSettings.n2 || 10; + n3Input.value = patternSettings.n3 || 10; + n4Input.value = patternSettings.n4 || 10; + + this.renderPresetColors(patternSettings.colors || ['#000000']); + } + + async savePreset() { + const nameInput = document.getElementById('preset-name-input'); + const patternSelect = document.getElementById('preset-pattern-select'); + const brightnessSlider = document.getElementById('preset-brightness-slider'); + const delayInput = document.getElementById('preset-delay-input'); + const n1Input = document.getElementById('preset-n1-input'); + const n2Input = document.getElementById('preset-n2-input'); + const n3Input = document.getElementById('preset-n3-input'); + const n4Input = document.getElementById('preset-n4-input'); + + const presetName = nameInput.value.trim(); + if (!presetName) { + alert('Please enter a preset name'); + return; + } + + if (!this.presetColors || this.presetColors.length === 0) { + alert('Please add at least one color'); + return; + } + + const presetData = { + name: presetName, + pattern: patternSelect.value, + brightness: parseInt(brightnessSlider.value), + delay: parseInt(delayInput.value), + n1: parseInt(n1Input.value), + n2: parseInt(n2Input.value), + n3: parseInt(n3Input.value), + n4: parseInt(n4Input.value), + colors: this.presetColors + }; + + try { + let response; + if (this.editingPresetName) { + // Update existing preset + response = await fetch(`/api/presets/${this.editingPresetName}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(presetData) + }); + } else { + // Create new preset + response = await fetch('/api/presets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(presetData) + }); + } + + if (response.ok) { + await this.loadPresets(); + this.hideModal('preset-editor-modal'); + this.editingPresetName = null; + } else { + const error = await response.json(); + alert(`Failed to save preset: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Failed to save preset:', error); + alert('Failed to save preset'); + } + } + hideModal(modalId) { document.getElementById(modalId).classList.remove('active'); } diff --git a/static/style.css b/static/style.css index ffba2b1..9822e4a 100644 --- a/static/style.css +++ b/static/style.css @@ -153,6 +153,37 @@ header h1 { font-size: 0.9rem; } +.left-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.left-panel-toggle { + padding: 0.25rem 0.5rem; + min-width: 32px; +} + +.left-panel-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.left-panel.collapsed { + flex: 0 0 48px; + padding-right: 0.5rem; +} + +.left-panel.collapsed .left-panel-body { + display: none; +} + +.left-panel.collapsed .left-panel-toggle { + transform: rotate(180deg); +} + .controls-section { display: flex; flex-direction: column; @@ -310,6 +341,7 @@ header h1 { } .patterns-section, +.presets-section, .color-palette-section { background-color: #1a1a1a; border: 2px solid #4a4a4a; @@ -318,6 +350,7 @@ header h1 { } .patterns-section h3, +.presets-section h3, .color-palette-section h3 { margin-bottom: 1rem; font-size: 1.1rem; @@ -329,6 +362,12 @@ header h1 { gap: 0.5rem; } +.presets-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + .pattern-button { padding: 0.75rem; background-color: #3a3a3a; @@ -350,6 +389,10 @@ header h1 { color: white; } +.pattern-button.default-preset { + border: 2px solid #6a5acd; +} + .color-palette { display: flex; flex-direction: column; diff --git a/templates/index.html b/templates/index.html index 0753ae8..04bc895 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@ Lighting Controller - +
@@ -15,6 +15,7 @@ +
@@ -26,60 +27,65 @@
-
- - +
+
+ + +
+
-
-

Color Palette

-
-
- - +
+
+

Color Palette

+
+
+ + +
-
-
-
- - - 127 +
+
+ + + 127 +
+
+ + + 100 ms +
-
- - - 100 ms -
-
-
-

N Parameters

-
-
- - -
-
- - -
-
- - -
-
- - +
+

N Parameters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-

Patterns

-
+
+

Presets

+
@@ -188,7 +194,86 @@
- + + + + + diff --git a/tmp_explanation.txt b/tmp_explanation.txt index 2902556..15e3659 100644 --- a/tmp_explanation.txt +++ b/tmp_explanation.txt @@ -1,2 +1,4 @@ This is just a placeholder to satisfy the tool requirement; actual code changes are in other files. + +