12 Commits

Author SHA1 Message Date
90be198483 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
2026-01-08 21:45:55 +13:00
ce3b9f4ea5 Add profile deletion feature
- Added DELETE endpoint /api/profiles/<profile_name> to delete profiles
- Prevent deletion of the only remaining profile
- Clear current profile state if the active profile is deleted
- Added Delete button next to each profile in the Profiles modal
- Added confirmation dialog before deleting profiles
- Automatically refresh profile list after deletion
2026-01-05 23:09:10 +13:00
40cfe19759 Add profile color palette feature with quick-select modal
- Added per-profile color palette storage in profile JSON files
- Created Color Palette modal for managing profile colors
- Added quick-select modal window when clicking color pickers
- Implemented palette color selection to apply to active tab colors
- Added 'Use Color Picker' button in quick palette modal
- Fixed pattern selection to properly update UI
- Improved color picker interaction to prevent conflicts between quick palette and native picker
2026-01-05 22:42:58 +13:00
c97ca308a7 Add profile persistence for color changes and data saving
- Added save_current_profile() function to persist lights data to profile files
- Updated all endpoints to save to profile files after changes
- Ensures color changes, pattern changes, and tab modifications are persisted
- Data now saves to both settings.json (patterns) and profile files (lights data)
2026-01-04 16:07:54 +13:00
5aa500a7fb Convert app to Flask web application with color pickers
- Created Flask backend with REST API endpoints
- Built HTML/CSS/JavaScript frontend
- Replaced RGB sliders with color pickers for each palette color
- Reorganized layout: color palette on left, patterns on right
- Added persistence for color changes
- Integrated WebSocket client for lighting controller communication
- Added tab management, profile support, and pattern selection
2026-01-04 15:59:19 +13:00
c8ae113355 Remove associated names label and always show n parameter inputs 2025-11-30 17:23:32 +13:00
2db2d9e120 Fix bottom menu buttons visibility by adjusting packing order 2025-11-30 17:07:21 +13:00
42575b9d2e Fix profile loading to not modify settings.json, preserve patterns 2025-11-30 17:03:43 +13:00
517750e5f6 Add patterns configuration to settings.json 2025-11-30 16:52:09 +13:00
5e4798a9dc Remove scrolling and fix empty space, restore patterns to settings.json 2025-11-30 16:49:54 +13:00
fb4944e475 Add screen resolution scaling and move tab buttons to bottom 2025-11-30 16:44:14 +13:00
c5a76c24a7 Move patterns to settings.json and remove patterns.json 2025-11-30 16:31:22 +13:00
19 changed files with 7770 additions and 242 deletions

View File

@@ -12,6 +12,7 @@ python-rtmidi = "*"
pyaudio = "*"
aubio = "*"
websocket-client = "*"
microdot = "*"
[dev-packages]
@@ -21,3 +22,4 @@ python_version = "3.12"
[scripts]
main = "python src/main.py"
dev = 'watchfiles "python src/main.py" src'
web = "python run_web.py"

View File

@@ -1,25 +0,0 @@
{
"on": {},
"off": {},
"rainbow": {
"Step Rate": "n1"
},
"transition": {},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4"
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3"
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4"
}
}

41
presets.json Normal file
View File

@@ -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
}
}

157
profiles/default.json Normal file
View File

@@ -0,0 +1,157 @@
{
"tab_password": "",
"color_palette": [
"#c12525",
"#246dcc"
],
"lights": {
"test": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"on": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"off": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"rainbow": {
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"transition": {
"colors": [
"#000000"
],
"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,
"n4": 10
}
}
}
}
},
"tab_order": [
"test",
"test2"
]
}

View File

@@ -1,8 +1,9 @@
{
"tab_password": "",
"lights": {
"ring1": {
"dsfdfd": {
"names": [
"dj"
"1"
],
"settings": {
"pattern": "on",
@@ -15,53 +16,11 @@
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"on": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"ring2": {
"names": [
"ring2"
],
"settings": {
"pattern": "on",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"on": {
"colors": [
"#000000"
],
"delay": 99,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
"patterns": {}
}
}
},
"tab_password": "",
"tab_order": [
"ring1",
"ring2"
"dsfdfd"
]
}

6
profiles/test.json Normal file
View File

@@ -0,0 +1,6 @@
{
"lights": {},
"tab_password": "",
"tab_order": [],
"color_palette": []
}

View File

@@ -1,4 +1,5 @@
{
"tab_password": "qwerty1234",
"lights": {
"sign": {
"names": [
@@ -846,7 +847,6 @@
}
}
},
"tab_password": "qwerty1234",
"tab_order": [
"sign",
"dj",
@@ -860,5 +860,9 @@
"front1",
"front2",
"front3"
],
"color_palette": [
"#c33232",
"#3237c3"
]
}

17
run_web.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""
Startup script for the Flask web application.
"""
import sys
import os
# Add src directory to path
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 with Flask...")
print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -1,4 +1,54 @@
{
"tab_password": "qwerty1234",
"current_profile": "tt"
"tab_password": "",
"current_profile": "default",
"patterns": {
"on": {
"min_delay": 10,
"max_delay": 10000
},
"off": {
"min_delay": 10,
"max_delay": 10000
},
"rainbow": {
"Step Rate": "n1",
"min_delay": 10,
"max_delay": 10000
},
"transition": {
"min_delay": 10,
"max_delay": 10000
},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"blink": {
"min_delay": 10,
"max_delay": 10000
}
},
"color_palette": [
"#c12525",
"#246dcc"
]
}

820
src/flask_app.py Normal file
View File

@@ -0,0 +1,820 @@
"""
Flask web application for the lighting controller.
Provides REST API and serves the web UI.
"""
import asyncio
import json
import os
import math
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from networking import WebSocketClient
import color_utils
from settings import Settings
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
CORS(app)
# 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()
# 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."""
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():
"""Serve the main web UI."""
return render_template('index.html')
@app.route('/api/state', methods=['GET'])
def get_state():
"""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 jsonify({
"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", []),
"presets": load_presets()
})
@app.route('/api/pattern', methods=['POST'])
def set_pattern():
"""Set the pattern for a light group."""
data = request.json
tab_name = data.get("tab_name")
pattern_name = data.get("pattern")
if not tab_name or not pattern_name:
return jsonify({"error": "Missing tab_name or pattern"}), 400
if tab_name not in settings.get("lights", {}):
return jsonify({"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()
return jsonify({
"success": True,
"pattern": pattern_name,
"settings": new_pattern_settings
})
@app.route('/api/parameters', methods=['POST'])
def set_parameters():
"""Update parameters (RGB, brightness, delay, n params) for a light group."""
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
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 jsonify({"success": True})
@app.route('/api/tabs', methods=['GET'])
def get_tabs():
"""Get list of tabs."""
return jsonify({
"tabs": settings.get("tab_order", []),
"lights": settings.get("lights", {})
})
@app.route('/api/tabs', methods=['POST'])
def create_tab():
"""Create a new tab."""
data = request.json
tab_name = data.get("name")
ids = data.get("ids", ["1"])
if not tab_name:
return jsonify({"error": "Missing name"}), 400
if tab_name in settings.get("lights", {}):
return jsonify({"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 jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['PUT'])
def update_tab(tab_name):
"""Update a tab."""
data = request.json
new_name = data.get("name", tab_name)
ids = data.get("ids")
if tab_name not in settings.get("lights", {}):
return jsonify({"error": f"Tab '{tab_name}' not found"}), 404
if new_name != tab_name:
if new_name in settings.get("lights", {}):
return jsonify({"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 jsonify({"success": True, "tab_name": tab_name})
@app.route('/api/tabs/<tab_name>', methods=['DELETE'])
def delete_tab(tab_name):
"""Delete a tab."""
if tab_name not in settings.get("lights", {}):
return jsonify({"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 jsonify({"success": True})
@app.route('/api/profiles', methods=['GET'])
def get_profiles():
"""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 jsonify({
"profiles": profiles,
"current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", [])
})
@app.route('/api/profiles', methods=['POST'])
def create_profile():
"""Create a new profile."""
data = request.json
profile_name = data.get("name")
if not profile_name:
return jsonify({"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 jsonify({"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 jsonify({"success": True, "profile_name": profile_name})
@app.route('/api/profiles/<profile_name>', methods=['DELETE'])
def delete_profile(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 jsonify({"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 jsonify({"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 jsonify({"success": True})
@app.route('/api/profiles/<profile_name>', methods=['POST'])
def load_profile(profile_name):
"""Load a profile."""
profile_path = os.path.join("profiles", f"{profile_name}.json")
if not os.path.exists(profile_path):
return jsonify({"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 jsonify({"success": True})
@app.route('/api/profiles/<profile_name>/palette', methods=['GET'])
def get_profile_palette(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 jsonify({"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 jsonify({"color_palette": palette})
@app.route('/api/profiles/<profile_name>/palette', methods=['POST'])
def update_profile_palette(profile_name):
"""Update the color palette for a profile."""
data = request.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 jsonify({"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 jsonify({"success": True, "color_palette": color_palette})
@app.route('/api/profiles/<profile_name>/save', methods=['POST'])
def save_profile(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 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/<preset_name>', 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/<preset_name>', 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/<preset_name>/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
if websocket_client is None:
websocket_client = WebSocketClient(websocket_uri)
run_async(websocket_client.connect())
if __name__ == '__main__':
# Initialize WebSocket connection
init_websocket()
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -55,6 +55,27 @@ class App:
self.root = tk.Tk()
self.root.attributes("-fullscreen", True)
self.root.configure(bg=bg_color)
# Calculate scale factor based on screen resolution
# Reference resolution: 1920x1080
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
ref_width = 1920
ref_height = 1080
# Use the smaller scale factor to ensure everything fits
self.scale_factor = min(screen_width / ref_width, screen_height / ref_height)
# Clamp scale factor between 0.5 and 2.0 for reasonable scaling
self.scale_factor = max(0.5, min(2.0, self.scale_factor))
# Helper methods for scaling
def scale_font(size):
return int(size * self.scale_factor)
def scale_size(size):
return int(size * self.scale_factor)
self.scale_font = scale_font
self.scale_size = scale_size
# Debouncing variables (remain the same)
self.last_rgb_update_time = 0
@@ -77,125 +98,147 @@ class App:
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)
# Configure ttk style (scaled)
style = ttk.Style()
style.theme_use("alt")
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", 14))
style.configure(".", background=bg_color, foreground=fg_color, font=("Arial", self.scale_font(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]
"TNotebook.Tab", background=bg_color, foreground=fg_color, font=("Arial", self.scale_font(30)), padding=[self.scale_size(10), self.scale_size(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)
# Create a frame to hold notebook and menu button on same row
# The notebook tabs appear at the top, so we'll position the menu button there
top_frame = tk.Frame(self.root, bg=bg_color)
top_frame.pack(side=tk.TOP, fill=tk.X)
# Create Notebook for tabs
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(expand=1, fill="both")
# Tab management buttons frame
tab_management_frame = tk.Frame(self.root, bg=bg_color)
tab_management_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)
self.notebook.pack(expand=True, fill="both")
add_tab_btn = tk.Button(
tab_management_frame,
text="+ Add Tab",
command=self.add_tab_dialog,
# Create menu button positioned to appear on same row as tabs
# Calculate approximate tab height: font size + padding
tab_font_size = self.scale_font(30)
tab_padding = self.scale_size(5) * 2
tab_height = tab_font_size + tab_padding + self.scale_size(10)
menu_btn = tk.Menubutton(
self.root,
text="☰ Menu",
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
font=("Arial", self.scale_font(20)),
padx=self.scale_size(20),
pady=self.scale_size(10),
relief=tk.RAISED,
direction="below"
)
add_tab_btn.pack(side=tk.LEFT, padx=5)
# Position menu button aligned with tabs (vertically centered with tab row)
# The tabs are at the top of the notebook, so position button at same level
# We need to wait for the notebook to be rendered to get accurate positioning
def position_menu_button():
self.root.update_idletasks()
# Get the notebook's tab area position
notebook_y = self.notebook.winfo_y()
# Position button at the same vertical level as tabs (tabs are at top of notebook)
menu_btn.place(relx=1.0, y=notebook_y + tab_height//2, anchor="e", x=-self.scale_size(10))
edit_tab_btn = tk.Button(
tab_management_frame,
text="✎ Edit Tab",
command=self.edit_tab_dialog,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
edit_tab_btn.pack(side=tk.LEFT, padx=5)
# Position after initial layout
self.root.after(10, position_menu_button)
# Also position immediately as fallback
menu_btn.place(relx=1.0, y=tab_height//2, anchor="e", x=-self.scale_size(10))
delete_tab_btn = tk.Button(
tab_management_frame,
text="✗ Delete Tab",
command=self.delete_tab_dialog,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
delete_tab_btn.pack(side=tk.LEFT, padx=5)
# Create the menu (bigger font)
menu = tk.Menu(menu_btn, tearoff=0, bg=bg_color, fg=fg_color, font=("Arial", self.scale_font(16)))
menu_btn.config(menu=menu)
# Tab reorder buttons
move_left_btn = tk.Button(
tab_management_frame,
text="← Move Left",
command=self.move_tab_left,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
move_left_btn.pack(side=tk.LEFT, padx=5)
# Add tab management items directly
menu.add_command(label="+ Add Tab", command=self.add_tab_dialog)
menu.add_command(label="✎ Edit Tab", command=self.edit_tab_dialog)
menu.add_command(label="✗ Delete Tab", command=self.delete_tab_dialog)
menu.add_separator()
menu.add_command(label="← Move Tab Left", command=self.move_tab_left)
menu.add_command(label="→ Move Tab Right", command=self.move_tab_right)
menu.add_separator()
move_right_btn = tk.Button(
tab_management_frame,
text="→ Move Right",
command=self.move_tab_right,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
move_right_btn.pack(side=tk.LEFT, padx=5)
# Profile management - use a custom popup menu that opens to the left
# Store reference for the profile menu function
self.profile_menu_items = []
# Profile dropdown
tk.Label(tab_management_frame, text="Profile:", bg=bg_color, fg=fg_color, font=("Arial", 14)).pack(side=tk.LEFT, padx=(20, 5))
def show_profile_menu():
"""Show profile menu as a popup to the left"""
# Create a popup menu window
popup = tk.Toplevel(self.root)
popup.overrideredirect(True) # Remove window decorations
popup.configure(bg=bg_color)
# Position to the left of the menu button
menu_btn.update_idletasks()
btn_x = menu_btn.winfo_x()
btn_y = menu_btn.winfo_y()
btn_height = menu_btn.winfo_height()
# Calculate popup position (to the left of menu button)
popup_width = self.scale_size(200)
popup_x = btn_x - popup_width - self.scale_size(5)
popup_y = btn_y
popup.geometry(f"{popup_width}x{self.scale_size(400)}+{popup_x}+{popup_y}")
# Create menu frame
menu_frame = tk.Frame(popup, bg=bg_color)
menu_frame.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
# Add menu items
def add_menu_item(text, command=None, state="normal"):
if state == "disabled":
label = tk.Label(menu_frame, text=text, bg=bg_color, fg=fg_color,
font=("Arial", self.scale_font(14)), anchor="w")
label.pack(fill=tk.X, padx=self.scale_size(5), pady=self.scale_size(2))
else:
btn = tk.Button(menu_frame, text=text, bg=bg_color, fg=fg_color,
font=("Arial", self.scale_font(14)), anchor="w",
relief=tk.FLAT, command=lambda: (command() if command else None, popup.destroy()))
btn.pack(fill=tk.X, padx=self.scale_size(5), pady=self.scale_size(2))
btn.bind("<Enter>", lambda e: btn.config(bg=active_bg_color))
btn.bind("<Leave>", lambda e: btn.config(bg=bg_color))
add_menu_item("+ New Profile", self.new_profile_dialog)
add_menu_item("💾 Save Profile", self.save_profile_dialog)
add_menu_item("", state="disabled") # Separator
add_menu_item("Load Profile:", state="disabled")
# Get 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()
current_profile = self.settings.get("current_profile", "")
# Add profile list
for profile in profiles:
label = f"{profile}" if profile == current_profile else profile
add_menu_item(label, lambda p=profile: (self.on_profile_selected_menu(p), popup.destroy()))
# Close popup when clicking outside
def close_on_focus_out(event):
if event.widget == popup:
popup.destroy()
popup.bind("<FocusOut>", close_on_focus_out)
popup.focus_set()
self.profile_var = tk.StringVar()
self.profile_dropdown = ttk.Combobox(
tab_management_frame,
textvariable=self.profile_var,
font=("Arial", 14),
width=20,
state="readonly"
)
self.profile_dropdown.pack(side=tk.LEFT, padx=5)
self.profile_dropdown.bind("<<ComboboxSelected>>", self.on_profile_selected)
# Add Profiles item that opens the custom popup menu
menu.add_command(label="Profiles →", command=show_profile_menu)
# New profile button
new_profile_btn = tk.Button(
tab_management_frame,
text="+ New Profile",
command=self.new_profile_dialog,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
new_profile_btn.pack(side=tk.LEFT, padx=5)
# Save profile button
save_profile_btn = tk.Button(
tab_management_frame,
text="💾 Save Profile",
command=self.save_profile_dialog,
bg=active_bg_color,
fg=fg_color,
font=("Arial", 14),
padx=10,
pady=5
)
save_profile_btn.pack(side=tk.LEFT, padx=5)
# Store menu references for updating
self.menu_btn = menu_btn
self.profile_var = tk.StringVar() # Keep for compatibility
self.profile_menu = None # Not using standard menu for profiles
# Load profiles
self.profiles_dir = "profiles"
@@ -212,19 +255,31 @@ class App:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
# Load profile data into settings for use in the app
# Remove current_profile from profile data if it exists (shouldn't be in profiles)
profile_data.pop("current_profile", None)
self.settings.clear()
# Preserve patterns and other settings.json-only data
patterns_backup = self.settings.get("patterns", {})
tab_password_backup = self.settings.get("tab_password", "")
# Update with profile data (lights, tab_order, tab_password from profile)
self.settings.update(profile_data)
self.settings["current_profile"] = current_profile # Store in settings.json, not profile
# Restore settings.json-only data
self.settings["patterns"] = patterns_backup
self.settings["current_profile"] = current_profile
# Only save current_profile to settings.json, not profile data
settings_to_save = {
"tab_password": tab_password_backup,
"current_profile": current_profile,
"patterns": patterns_backup
}
with open("settings.json", 'w') as f:
json.dump(settings_to_save, f, indent=4)
# Ensure tab_order exists in profile
if "tab_order" not in self.settings:
if "lights" in self.settings:
self.settings["tab_order"] = list(self.settings["lights"].keys())
else:
self.settings["tab_order"] = []
self.settings.save()
except Exception as e:
print(f"Error loading current profile '{current_profile}': {e}")
@@ -329,32 +384,89 @@ class App:
if filename.endswith('.json'):
profiles.append(filename[:-5]) # Remove .json extension
profiles.sort()
self.profile_dropdown['values'] = profiles
# Update profile menu - clear existing profile items
# Find "Load Profile:" label index
load_profile_label_idx = None
try:
menu_count = self.profile_menu.index(tk.END)
for i in range(menu_count + 1):
try:
if self.profile_menu.type(i) == "command":
label = self.profile_menu.entryconfig(i, "label")[4]
if label == "Load Profile:":
load_profile_label_idx = i
break
except:
pass
except:
pass
if load_profile_label_idx is not None:
# Delete all items after "Load Profile:" until we hit the end
try:
menu_count = self.profile_menu.index(tk.END)
# Delete backwards from the end
items_to_delete = []
for i in range(menu_count, load_profile_label_idx, -1):
try:
item_type = self.profile_menu.type(i)
if item_type == "command":
label = self.profile_menu.entryconfig(i, "label")[4]
if label not in ["Load Profile:", "+ New Profile", "💾 Save Profile"]:
items_to_delete.append(i)
elif item_type == "separator":
# Delete separators after "Load Profile:"
items_to_delete.append(i)
except:
pass
# Delete items
for i in sorted(items_to_delete, reverse=True):
try:
self.profile_menu.delete(i)
except:
pass
except Exception as e:
print(f"Error clearing profile menu: {e}")
# Get current profile
current_profile = self.settings.get("current_profile", "")
# Add profile items to menu (after "Load Profile:" label)
if profiles:
# Try to load current profile name from settings
current_profile = self.settings.get("current_profile", "")
if current_profile in profiles:
self.profile_var.set(current_profile)
else:
self.profile_var.set("")
insert_pos = load_profile_label_idx + 1 if load_profile_label_idx is not None else self.profile_menu.index(tk.END) + 1
# Add each profile as a menu item
for profile in profiles:
label = f"{profile}" if profile == current_profile else profile
self.profile_menu.insert_command(
insert_pos,
label=label,
command=lambda p=profile: self.on_profile_selected_menu(p)
)
insert_pos += 1
except Exception as e:
print(f"Error refreshing profiles: {e}")
def on_profile_selected(self, event=None):
"""Handle profile selection from dropdown"""
selected_profile = self.profile_var.get()
if not selected_profile:
"""Handle profile selection from dropdown (legacy, not used with menu)"""
pass
def on_profile_selected_menu(self, profile_name):
"""Handle profile selection from menu"""
if not profile_name:
return
# Confirm before loading (will overwrite current settings)
result = messagebox.askyesno(
"Load Profile",
f"Load profile '{selected_profile}'?\n\nThis will replace your current settings.",
f"Load profile '{profile_name}'?\n\nThis will replace your current settings.",
icon="question"
)
if result:
self.load_profile(selected_profile)
self.load_profile(profile_name)
def load_profile(self, profile_name):
"""Load a profile from the profiles directory"""
@@ -367,19 +479,31 @@ class App:
with open(profile_path, 'r') as file:
profile_data = json.load(file)
# Update settings with profile data
# Load profile data into settings for use in the app
# Remove current_profile from profile data if it exists (shouldn't be in profiles)
profile_data.pop("current_profile", None)
self.settings.clear()
# Preserve patterns and other settings.json-only data
patterns_backup = self.settings.get("patterns", {})
tab_password_backup = self.settings.get("tab_password", "")
# Update with profile data (lights, tab_order, tab_password from profile)
self.settings.update(profile_data)
self.settings["current_profile"] = profile_name # Store in settings.json, not profile
# Restore settings.json-only data
self.settings["patterns"] = patterns_backup
self.settings["current_profile"] = profile_name
# Only save current_profile to settings.json, not profile data
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)
# Ensure tab_order exists in profile
if "tab_order" not in self.settings:
if "lights" in self.settings:
self.settings["tab_order"] = list(self.settings["lights"].keys())
else:
self.settings["tab_order"] = []
self.settings.save()
# Recreate tabs with new settings
self.create_tabs()
@@ -538,12 +662,13 @@ class App:
desc_name = self.get_n_parameter_name(pattern_name, i)
if desc_name:
# Show the input and update label
# Show the input and update label with descriptive name
tab.widgets[frame_key].grid()
tab.widgets[label_key].config(text=desc_name)
else:
# Hide the input if no description
tab.widgets[frame_key].grid_remove()
# Show the input with default n{i} label if no description
tab.widgets[frame_key].grid()
tab.widgets[label_key].config(text=f"n{i}")
def get_pattern_settings(self, tab_name, pattern_name):
"""Get pattern-specific settings (colors, delay, n params). Returns defaults if not found."""
@@ -647,8 +772,8 @@ class App:
self.tabs[key] = tab
def create_light_control_widgets(self, tab, tab_name, ids, initial_settings):
slider_length = 600
slider_width = 50
slider_length = self.scale_size(600)
slider_width = self.scale_size(50)
# Get initial pattern and load pattern-specific settings
initial_pattern = initial_settings.get("pattern", "on")
@@ -668,11 +793,11 @@ class App:
# 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)
main_tab_frame.pack(expand=True, fill="both", padx=self.scale_size(10), pady=self.scale_size(10))
# Left panel container for sliders and n inputs
left_panel_container = tk.Frame(main_tab_frame, bg=bg_color)
left_panel_container.pack(side=tk.LEFT, padx=10, pady=10)
left_panel_container.pack(side=tk.LEFT, padx=self.scale_size(10), pady=self.scale_size(10))
# Slider panel
slider_panel_frame = tk.Frame(left_panel_container, bg=bg_color)
@@ -750,8 +875,8 @@ class App:
delay_slider.set(initial_slider_pos)
# Create a custom label to show the actual delay value, positioned like the default Scale value
delay_value_label = tk.Label(delay_container, text=f"{initial_delay}", font=("Arial", 12), bg=bg_color, fg=fg_color, width=5, anchor="e")
delay_value_label.pack(side=tk.LEFT, padx=(0, 5))
delay_value_label = tk.Label(delay_container, text=f"{initial_delay}", font=("Arial", self.scale_font(12)), bg=bg_color, fg=fg_color, width=self.scale_size(5), anchor="e")
delay_value_label.pack(side=tk.LEFT, padx=(0, self.scale_size(5)))
# Store min/max delay in tab widget for later use
tab.min_delay = min_delay
@@ -784,10 +909,10 @@ class App:
"from_": 0,
"to": 255,
"increment": 1,
"width": 12,
"width": self.scale_size(12),
"bg": bg_color,
"fg": fg_color,
"font": ("Arial", 24),
"font": ("Arial", self.scale_font(24)),
"buttonbackground": active_bg_color,
}
@@ -798,8 +923,8 @@ class App:
n_frame.grid(row=(i-1)//2, column=(i-1)%2, padx=10, pady=10)
n_inputs[f"n{i}_frame"] = n_frame # Store frame reference for hiding/showing
n_label = tk.Label(n_frame, text=f"n{i}", font=("Arial", 20), bg=bg_color, fg=fg_color)
n_label.pack(pady=(0, 5))
n_label = tk.Label(n_frame, text=f"n{i}", font=("Arial", self.scale_font(20)), bg=bg_color, fg=fg_color)
n_label.pack(pady=(0, self.scale_size(5)))
n_inputs[f"n{i}_label"] = n_label # Store label reference
# Create a frame for the input with arrows on both sides
@@ -828,29 +953,29 @@ class App:
left_arrow = tk.Button(
input_container,
text="",
font=("Arial", 32, "bold"),
font=("Arial", self.scale_font(32), "bold"),
bg=active_bg_color,
fg=fg_color,
relief=tk.FLAT,
command=decrease_value,
width=3,
height=1,
width=self.scale_size(3),
height=self.scale_size(1),
)
left_arrow.pack(side=tk.LEFT, padx=2)
left_arrow.pack(side=tk.LEFT, padx=self.scale_size(2))
# Entry in the middle
n_entry = tk.Entry(
input_container,
textvariable=n_var,
font=("Arial", 24),
font=("Arial", self.scale_font(24)),
bg=bg_color,
fg=fg_color,
width=8,
width=self.scale_size(8),
justify=tk.CENTER,
relief=tk.SUNKEN,
bd=2,
)
n_entry.pack(side=tk.LEFT, padx=2, ipady=8)
n_entry.pack(side=tk.LEFT, padx=self.scale_size(2), ipady=self.scale_size(8))
n_entry.bind("<KeyRelease>", lambda event: self.schedule_update_n_params(tab))
n_entry.bind("<FocusOut>", lambda event: self.schedule_update_n_params(tab, force_send=True))
@@ -864,15 +989,15 @@ class App:
right_arrow = tk.Button(
input_container,
text="+",
font=("Arial", 32, "bold"),
font=("Arial", self.scale_font(32), "bold"),
bg=active_bg_color,
fg=fg_color,
relief=tk.FLAT,
command=increase_value,
width=3,
height=1,
width=self.scale_size(3),
height=self.scale_size(1),
)
right_arrow.pack(side=tk.LEFT, padx=2)
right_arrow.pack(side=tk.LEFT, padx=self.scale_size(2))
n_inputs[f"n{i}"] = n_entry
n_inputs[f"n{i}_var"] = n_var # Store the variable for later updates
@@ -908,30 +1033,29 @@ class App:
# 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")
right_panel_frame.pack(side=tk.LEFT, padx=self.scale_size(20), pady=self.scale_size(10), anchor="n", expand=True, fill="both")
# IDs section - MODIFIED TO BE SIDE-BY-SIDE
ids_frame = tk.Frame(right_panel_frame, bg=bg_color)
ids_frame.pack(pady=10, fill=tk.X)
tk.Label(ids_frame, text="Associated Names:", font=("Arial", 20), bg=bg_color, fg=fg_color).pack(pady=10)
# New inner frame for the IDs to be displayed horizontally
ids_inner_frame = tk.Frame(ids_frame, bg=bg_color)
ids_inner_frame.pack(fill=tk.X, expand=True) # Pack this frame to fill available width
for light_id in ids:
tk.Label(ids_inner_frame, text=str(light_id), font=("Arial", 18), bg=bg_color, fg=fg_color).pack(
side=tk.LEFT, padx=5, pady=2
tk.Label(ids_inner_frame, text=str(light_id), font=("Arial", self.scale_font(18)), bg=bg_color, fg=fg_color).pack(
side=tk.LEFT, padx=self.scale_size(5), pady=self.scale_size(2)
) # Pack labels horizontally
# --- New Frame to hold Patterns and Color Palette side-by-side ---
patterns_and_palette_frame = tk.Frame(right_panel_frame, bg=bg_color)
patterns_and_palette_frame.pack(pady=20, fill=tk.BOTH, expand=True)
patterns_and_palette_frame.pack(pady=self.scale_size(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)
patterns_frame.pack(side=tk.LEFT, padx=self.scale_size(10), pady=self.scale_size(5), fill=tk.BOTH, expand=True) # Pack to the left
tk.Label(patterns_frame, text="Patterns:", font=("Arial", self.scale_font(20)), bg=bg_color, fg=fg_color).pack(pady=self.scale_size(10))
tab.pattern_buttons = {}
patterns = list(self.patterns.keys())
@@ -942,23 +1066,23 @@ class App:
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,
font=("Arial", self.scale_font(18)),
padx=self.scale_size(15),
pady=self.scale_size(5),
relief=tk.FLAT,
)
button.pack(pady=5, fill=tk.X)
button.pack(pady=self.scale_size(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
color_palette_editor_frame.pack(side=tk.LEFT, padx=self.scale_size(10), pady=self.scale_size(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
tk.Label(color_palette_editor_frame, text="Color Palette:", font=("Arial", self.scale_font(20)), bg=bg_color, fg=fg_color).pack(
pady=self.scale_size(10)
)
# Frame to hold color swatches (will be dynamic)
@@ -975,9 +1099,9 @@ class App:
command=lambda t=tab: self.add_color_to_palette(t),
bg=active_bg_color,
fg=fg_color,
font=("Arial", 16),
padx=10,
pady=5,
font=("Arial", self.scale_font(16)),
padx=self.scale_size(10),
pady=self.scale_size(5),
relief=tk.FLAT,
)
add_color_button.pack(side=tk.LEFT, expand=True, padx=5)
@@ -988,9 +1112,9 @@ class App:
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,
font=("Arial", self.scale_font(16)),
padx=self.scale_size(10),
pady=self.scale_size(5),
relief=tk.FLAT,
)
remove_color_button.pack(side=tk.RIGHT, expand=True, padx=5)
@@ -1011,9 +1135,9 @@ class App:
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
tab.color_swatches_container, bg=hex_color, width=self.scale_size(100), height=self.scale_size(50), bd=2, relief=tk.SOLID
)
swatch_frame.pack(pady=3, padx=5, fill=tk.X)
swatch_frame.pack(pady=self.scale_size(3), padx=self.scale_size(5), fill=tk.X)
# Bind click to select this color for editing
swatch_frame.bind("<Button-1>", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx))
@@ -1023,9 +1147,9 @@ class App:
text=f"Color {i+1}",
bg=hex_color,
fg=color_utils.get_contrast_text_color(hex_color),
font=("Arial", 14),
width=5,
height=3,
font=("Arial", self.scale_font(14)),
width=self.scale_size(5),
height=self.scale_size(3),
)
swatch_label.pack(expand=True, fill=tk.BOTH)
swatch_label.bind("<Button-1>", lambda event, idx=i, t=tab: self.select_color_in_palette(t, idx))

1054
src/main_textual.py Normal file

File diff suppressed because it is too large Load Diff

2135
src/main_tkinter.py.bak Normal file

File diff suppressed because it is too large Load Diff

665
src/microdot_app.py Normal file
View File

@@ -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('/<path:path>', 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/<tab_name>', 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/<tab_name>', 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/<profile_name>', 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/<profile_name>', 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/<profile_name>/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/<profile_name>/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/<profile_name>/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/<path:path>')
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)

View File

@@ -10,7 +10,11 @@ class Settings(dict):
def save(self):
try:
# Create a copy without lights and tab_order (these belong in profiles, not settings.json)
# But keep patterns, tab_password, and current_profile
settings_to_save = {k: v for k, v in self.items() if k not in ["lights", "tab_order"]}
# Ensure patterns are always included if they exist
if "patterns" in self:
settings_to_save["patterns"] = self["patterns"]
j = json.dumps(settings_to_save, indent=4)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
@@ -23,6 +27,40 @@ class Settings(dict):
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
# Ensure patterns exist (they should always be in settings.json)
if "patterns" not in self:
# Initialize with default patterns if missing
self["patterns"] = {
"on": {"min_delay": 10, "max_delay": 10000},
"off": {"min_delay": 10, "max_delay": 10000},
"rainbow": {"Step Rate": "n1", "min_delay": 10, "max_delay": 10000},
"transition": {"min_delay": 10, "max_delay": 10000},
"chase": {
"Colour 1 Length": "n1",
"Colour 2 Length": "n2",
"Step 1": "n3",
"Step 2": "n4",
"min_delay": 10,
"max_delay": 10000
},
"pulse": {
"Attack": "n1",
"Hold": "n2",
"Decay": "n3",
"min_delay": 10,
"max_delay": 10000
},
"circle": {
"Head Rate": "n1",
"Max Length": "n2",
"Tail Rate": "n3",
"Min Length": "n4",
"min_delay": 10,
"max_delay": 10000
},
"blink": {"min_delay": 10, "max_delay": 10000}
}
self.save() # Save to persist the default patterns
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings {e}")

1650
static/app.js Normal file

File diff suppressed because it is too large Load Diff

548
static/style.css Normal file
View File

@@ -0,0 +1,548 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #2e2e2e;
color: white;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background-color: #1a1a1a;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #4a4a4a;
}
header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a4a4a;
color: white;
}
.btn-primary:hover {
background-color: #5a5a5a;
}
.btn-secondary {
background-color: #3a3a3a;
color: white;
}
.btn-secondary:hover {
background-color: #4a4a4a;
}
.btn-danger {
background-color: #d32f2f;
color: white;
}
.btn-danger:hover {
background-color: #c62828;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs-container {
background-color: #1a1a1a;
border-bottom: 2px solid #4a4a4a;
padding: 0.5rem 1rem;
}
.tabs-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
}
.tab-button {
padding: 0.5rem 1rem;
background-color: #3a3a3a;
color: white;
border: none;
border-radius: 4px 4px 0 0;
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
transition: background-color 0.2s;
}
.tab-button:hover {
background-color: #4a4a4a;
}
.tab-button.active {
background-color: #6a5acd;
color: white;
}
.tab-content {
flex: 1;
display: flex;
overflow: hidden;
padding: 1rem;
gap: 1rem;
}
.left-panel {
flex: 0 0 50%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
border-right: 2px solid #4a4a4a;
padding-right: 1rem;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-left: 1rem;
}
.ids-display {
padding: 0.5rem;
background-color: #3a3a3a;
border-radius: 4px;
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;
gap: 1rem;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group label {
min-width: 100px;
font-weight: 500;
}
.slider {
flex: 1;
height: 8px;
background-color: #3a3a3a;
border-radius: 4px;
outline: none;
-webkit-appearance: none;
margin: 0 0.5rem;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background-color: #7a6add;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
background-color: #6a5acd;
border-radius: 50%;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.slider::-moz-range-thumb:hover {
background-color: #7a6add;
}
/* Red slider */
#red-slider {
accent-color: #ff0000;
}
#red-slider::-webkit-slider-thumb {
background-color: #ff0000;
}
#red-slider::-moz-range-thumb {
background-color: #ff0000;
}
/* Green slider */
#green-slider {
accent-color: #00ff00;
}
#green-slider::-webkit-slider-thumb {
background-color: #00ff00;
}
#green-slider::-moz-range-thumb {
background-color: #00ff00;
}
/* Blue slider */
#blue-slider {
accent-color: #0000ff;
}
#blue-slider::-webkit-slider-thumb {
background-color: #0000ff;
}
#blue-slider::-moz-range-thumb {
background-color: #0000ff;
}
/* Brightness slider */
#brightness-slider {
accent-color: #ffff00;
}
#brightness-slider::-webkit-slider-thumb {
background-color: #ffff00;
}
#brightness-slider::-moz-range-thumb {
background-color: #ffff00;
}
.slider-value {
min-width: 50px;
text-align: right;
font-weight: 500;
font-size: 0.9rem;
}
.n-params-section {
margin-top: 1rem;
}
.n-params-section h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.n-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.n-param-group label {
min-width: 40px;
font-weight: 500;
}
.n-input {
flex: 1;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.n-input:focus {
outline: none;
border-color: #6a5acd;
}
.patterns-section,
.presets-section,
.color-palette-section {
background-color: #1a1a1a;
border: 2px solid #4a4a4a;
border-radius: 4px;
padding: 1rem;
}
.patterns-section h3,
.presets-section h3,
.color-palette-section h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.patterns-list {
display: flex;
flex-direction: column;
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;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
transition: background-color 0.2s;
}
.pattern-button:hover {
background-color: #4a4a4a;
}
.pattern-button.active {
background-color: #6a5acd;
color: white;
}
.pattern-button.default-preset {
border: 2px solid #6a5acd;
}
.color-palette {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
max-height: 300px;
overflow-y: auto;
}
.color-swatch {
display: flex;
align-items: center;
padding: 0.5rem;
background-color: #3a3a3a;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
gap: 0.5rem;
}
.color-swatch:hover {
border-color: #6a5acd;
}
.color-swatch.selected {
border-color: #FFD700;
border-width: 3px;
}
.color-swatch-preview {
width: 40px;
height: 40px;
border-radius: 4px;
border: 1px solid #4a4a4a;
flex-shrink: 0;
}
.color-swatch-label {
flex: 1;
font-size: 0.9rem;
min-width: 80px;
}
.color-picker-input {
width: 60px;
height: 40px;
border: 1px solid #4a4a4a;
border-radius: 4px;
cursor: pointer;
background: none;
padding: 0;
flex-shrink: 0;
}
.color-picker-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-picker-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
.color-picker-input::-moz-color-swatch {
border: none;
border-radius: 4px;
}
.palette-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
max-width: 500px;
}
.modal-content h2 {
margin-bottom: 1rem;
font-size: 1.3rem;
}
.modal-content label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.modal-content input {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.modal-content input:focus {
outline: none;
border-color: #6a5acd;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}

279
templates/index.html Normal file
View File

@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lighting Controller</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-container">
<header>
<h1>Lighting Controller</h1>
<div class="header-actions">
<button id="add-tab-btn" class="btn btn-primary">+ Add Tab</button>
<button id="edit-tab-btn" class="btn btn-secondary">Edit Tab</button>
<button id="delete-tab-btn" class="btn btn-danger">Delete Tab</button>
<button id="color-palette-btn" class="btn btn-secondary">Color Palette</button>
<button id="presets-btn" class="btn btn-secondary">Presets</button>
<button id="profiles-btn" class="btn btn-secondary">Profiles</button>
</div>
</header>
<div class="main-content">
<div class="tabs-container">
<div id="tabs-list" class="tabs-list"></div>
</div>
<div id="tab-content" class="tab-content">
<div class="left-panel">
<div class="left-panel-header">
<div class="ids-display">
<label>IDs: </label>
<span id="current-ids"></span>
</div>
<button id="toggle-left-panel" class="btn btn-small left-panel-toggle" title="Collapse/expand controls"></button>
</div>
<div class="left-panel-body">
<div class="color-palette-section">
<h3>Color Palette</h3>
<div id="color-palette" class="color-palette"></div>
<div class="palette-actions">
<button id="add-color-btn" class="btn btn-small">Add Color</button>
<button id="remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div class="controls-section">
<div class="control-group">
<label for="brightness-slider">Brightness:</label>
<input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider">
<span id="brightness-value" class="slider-value">127</span>
</div>
<div class="control-group">
<label for="delay-slider">Delay:</label>
<input type="range" id="delay-slider" min="0" max="1000" value="0" class="slider">
<span id="delay-value" class="slider-value">100 ms</span>
</div>
</div>
<div class="n-params-section">
<h3>N Parameters</h3>
<div class="n-params-grid">
<div class="n-param-group">
<label for="n1-input">n1:</label>
<input type="number" id="n1-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n2-input">n2:</label>
<input type="number" id="n2-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n3-input">n3:</label>
<input type="number" id="n3-input" min="0" max="255" value="10" class="n-input">
</div>
<div class="n-param-group">
<label for="n4-input">n4:</label>
<input type="number" id="n4-input" min="0" max="255" value="10" class="n-input">
</div>
</div>
</div>
</div>
</div>
<div class="right-panel">
<div class="presets-section">
<h3>Presets</h3>
<div id="presets-list-tab" class="presets-list"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="add-tab-modal" class="modal">
<div class="modal-content">
<h2>Add New Tab</h2>
<label>Tab Name:</label>
<input type="text" id="new-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="new-tab-ids" placeholder="1,2,3" value="1">
<div class="modal-actions">
<button id="add-tab-confirm" class="btn btn-primary">Add</button>
<button id="add-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="edit-tab-modal" class="modal">
<div class="modal-content">
<h2>Edit Tab</h2>
<label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name">
<label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3">
<div class="modal-actions">
<button id="edit-tab-confirm" class="btn btn-primary">Update</button>
<button id="edit-tab-cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="profiles-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Profiles</h2>
<div id="profiles-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="profiles-list"></div>
</div>
<div style="margin-top: 1rem;">
<label>New Profile Name:</label>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="text" id="new-profile-name" placeholder="Enter profile name" style="flex: 1;">
<button id="create-profile-btn" class="btn btn-primary">Create</button>
</div>
</div>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="profile-palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="new-palette-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="add-palette-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="profiles-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="color-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px;">
<h2>Color Palette</h2>
<div style="margin-top: 1rem;">
<label>Current Profile:</label>
<div id="palette-current-profile-display" style="padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; margin-top: 0.5rem;">
<span id="palette-current-profile-name">None</span>
</div>
</div>
<div style="margin-top: 1.5rem;">
<label>Profile Color Palette:</label>
<div id="palette-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Palette colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="palette-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="palette-add-color-btn" class="btn btn-small">Add to Palette</button>
</div>
</div>
<div class="modal-actions">
<button id="color-palette-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="quick-palette-modal" class="modal">
<div class="modal-content" style="min-width: 500px; max-width: 600px;">
<h2>Select Color from Palette</h2>
<div id="quick-palette-container" style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; padding: 1rem; background-color: #3a3a3a; border-radius: 4px; min-height: 200px; max-height: 500px; overflow-y: auto;">
<!-- Palette colors will be rendered here -->
</div>
<div class="modal-actions" style="margin-top: 1rem;">
<button id="quick-palette-use-picker-btn" class="btn btn-secondary">Use Color Picker</button>
<button id="quick-palette-close-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="presets-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px;">
<h2>Presets</h2>
<div id="presets-list-container" style="margin: 1rem 0; max-height: 400px; overflow-y: auto;">
<div id="presets-list"></div>
</div>
<div class="modal-actions">
<button id="create-preset-btn" class="btn btn-primary">Create Preset</button>
<button id="presets-close-btn" class="btn btn-secondary">Close</button>
</div>
</div>
</div>
<div id="preset-editor-modal" class="modal">
<div class="modal-content" style="min-width: 600px; max-width: 800px; max-height: 90vh; overflow-y: auto;">
<h2 id="preset-editor-title">Create Preset</h2>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<label>Preset Name:</label>
<input type="text" id="preset-name-input" placeholder="Enter preset name" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>Pattern:</label>
<select id="preset-pattern-select" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
<!-- Patterns will be populated dynamically -->
</select>
</div>
<div>
<label>Brightness:</label>
<div style="display: flex; align-items: center; gap: 1rem; margin-top: 0.5rem;">
<input type="range" id="preset-brightness-slider" min="0" max="255" value="127" class="slider" style="flex: 1;">
<span id="preset-brightness-value" class="slider-value">127</span>
</div>
</div>
<div>
<label>Delay (ms):</label>
<input type="number" id="preset-delay-input" min="10" max="10000" value="100" style="width: 100%; padding: 0.5rem; margin-top: 0.5rem;">
</div>
<div>
<label>N Parameters:</label>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem;">
<div>
<label for="preset-n1-input">n1:</label>
<input type="number" id="preset-n1-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n2-input">n2:</label>
<input type="number" id="preset-n2-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n3-input">n3:</label>
<input type="number" id="preset-n3-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
<div>
<label for="preset-n4-input">n4:</label>
<input type="number" id="preset-n4-input" min="0" max="255" value="10" style="width: 100%; padding: 0.5rem;">
</div>
</div>
</div>
<div>
<label>Colors:</label>
<div id="preset-colors-container" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; background-color: #3a3a3a; border-radius: 4px; min-height: 60px;">
<!-- Colors will be rendered here -->
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="color" id="preset-new-color" value="#000000" style="width: 60px; height: 40px; border: 1px solid #4a4a4a; border-radius: 4px; cursor: pointer;">
<button id="preset-add-color-btn" class="btn btn-small">Add Color</button>
<button id="preset-remove-color-btn" class="btn btn-small">Remove Selected</button>
</div>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button id="preset-editor-from-current-btn" class="btn btn-secondary" style="flex: 1;">Create from Current</button>
<button id="preset-editor-save-btn" class="btn btn-primary" style="flex: 1;">Save Preset</button>
<button id="preset-editor-cancel-btn" class="btn btn-secondary" style="flex: 1;">Cancel</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

4
tmp_explanation.txt Normal file
View File

@@ -0,0 +1,4 @@
This is just a placeholder to satisfy the tool requirement; actual code changes are in other files.