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
This commit is contained in:
2026-01-08 21:45:55 +13:00
parent ce3b9f4ea5
commit 90be198483
10 changed files with 1758 additions and 71 deletions

View File

@@ -12,8 +12,7 @@ python-rtmidi = "*"
pyaudio = "*" pyaudio = "*"
aubio = "*" aubio = "*"
websocket-client = "*" websocket-client = "*"
flask = "*" microdot = "*"
flask-cors = "*"
[dev-packages] [dev-packages]

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

View File

@@ -1,12 +1,16 @@
{ {
"tab_password": "", "tab_password": "",
"color_palette": [
"#c12525",
"#246dcc"
],
"lights": { "lights": {
"test": { "test": {
"names": [ "names": [
"test" "test"
], ],
"settings": { "settings": {
"pattern": "transition", "pattern": "pulse",
"brightness": 127, "brightness": 127,
"colors": [ "colors": [
"#000000" "#000000"
@@ -49,10 +53,94 @@
}, },
"transition": { "transition": {
"colors": [ "colors": [
"#c12525", "#000000"
"#246dcc"
], ],
"delay": 1321, "delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
}
}
}
},
"test2": {
"names": [
"test"
],
"settings": {
"pattern": "pulse",
"brightness": 127,
"colors": [
"#000000"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10,
"patterns": {
"blink": {
"colors": [
"#12b533"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"circle": {
"colors": [
"#9d3434",
"#cb5d5d"
],
"delay": 100,
"n1": 10,
"n2": 10,
"n3": 10,
"n4": 10
},
"pulse": {
"colors": [
"#9f1d1d",
"#176d2d",
"#50309c"
],
"delay": 300,
"n1": 10, "n1": 10,
"n2": 10, "n2": 10,
"n3": 10, "n3": 10,
@@ -63,10 +151,7 @@
} }
}, },
"tab_order": [ "tab_order": [
"test" "test",
], "test2"
"color_palette": [
"#c12525",
"#246dcc"
] ]
} }

View File

@@ -11,7 +11,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from flask_app import app from flask_app import app
if __name__ == '__main__': if __name__ == '__main__':
print("Starting Lighting Controller Web App...") print("Starting Lighting Controller Web App with Flask...")
print("Open http://localhost:5000 in your browser") print("Open http://localhost:5000 in your browser")
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -41,6 +41,9 @@ def load_current_profile():
settings.update(profile_data) settings.update(profile_data)
settings["patterns"] = patterns_backup settings["patterns"] = patterns_backup
settings["current_profile"] = current_profile 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.") print(f"Loaded profile '{current_profile}' on startup.")
except Exception as e: except Exception as e:
print(f"Error loading profile '{current_profile}': {e}") print(f"Error loading profile '{current_profile}': {e}")
@@ -48,6 +51,29 @@ def load_current_profile():
# Load current profile when module is imported # Load current profile when module is imported
load_current_profile() 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): def delay_to_slider(delay_ms, min_delay=10, max_delay=10000):
"""Convert delay in ms to slider position (0-1000) using logarithmic scale.""" """Convert delay in ms to slider position (0-1000) using logarithmic scale."""
@@ -142,6 +168,9 @@ def save_current_profile():
profile_data = dict(settings) profile_data = dict(settings)
profile_data.pop("current_profile", None) profile_data.pop("current_profile", None)
profile_data.pop("patterns", None) # Patterns stay in settings.json 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: with open(profile_path, 'w') as file:
json.dump(profile_data, file, indent=4) json.dump(profile_data, file, indent=4)
@@ -206,7 +235,8 @@ def get_state():
"patterns": settings.get("patterns", {}), "patterns": settings.get("patterns", {}),
"tab_order": settings.get("tab_order", []), "tab_order": settings.get("tab_order", []),
"current_profile": settings.get("current_profile", ""), "current_profile": settings.get("current_profile", ""),
"color_palette": settings.get("color_palette", []) "color_palette": settings.get("color_palette", []),
"presets": load_presets()
}) })
@@ -619,6 +649,161 @@ def save_profile(profile_name):
return jsonify({"success": True}) 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(): def init_websocket():
"""Initialize WebSocket connection in background.""" """Initialize WebSocket connection in background."""
global websocket_client global websocket_client

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

@@ -5,7 +5,8 @@ class LightingController {
this.state = { this.state = {
lights: {}, lights: {},
patterns: {}, patterns: {},
tab_order: [] tab_order: [],
presets: {}
}; };
this.selectedColorIndex = 0; this.selectedColorIndex = 0;
this.updateTimeouts = {}; this.updateTimeouts = {};
@@ -28,6 +29,10 @@ class LightingController {
const response = await fetch('/api/state'); const response = await fetch('/api/state');
const data = await response.json(); const data = await response.json();
this.state = data; this.state = data;
// Ensure presets is always an object
if (!this.state.presets) {
this.state.presets = {};
}
// Update current profile display // Update current profile display
this.updateCurrentProfileDisplay(); this.updateCurrentProfileDisplay();
// Update current profile display if profiles modal is open // Update current profile display if profiles modal is open
@@ -54,6 +59,7 @@ class LightingController {
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal()); document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab()); document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette()); document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles()); document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions // Modal actions
@@ -63,11 +69,22 @@ class LightingController {
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal')); document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal')); document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal')); document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
document.getElementById('create-preset-btn').addEventListener('click', () => this.showPresetEditor());
document.getElementById('preset-editor-save-btn').addEventListener('click', () => this.savePreset());
document.getElementById('preset-editor-cancel-btn').addEventListener('click', () => this.hideModal('preset-editor-modal'));
document.getElementById('preset-add-color-btn').addEventListener('click', () => this.addPresetColor());
document.getElementById('preset-remove-color-btn').addEventListener('click', () => this.removePresetColor());
document.getElementById('preset-editor-from-current-btn').addEventListener('click', () => this.loadCurrentTabToPresetEditor());
document.getElementById('preset-brightness-slider').addEventListener('input', (e) => {
document.getElementById('preset-brightness-value').textContent = e.target.value;
});
document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal()); document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal());
document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette()); document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette());
document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile()); document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile());
document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor()); document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor());
document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal()); document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal());
document.getElementById('toggle-left-panel').addEventListener('click', () => this.toggleLeftPanel());
// Enter key for new profile name // Enter key for new profile name
document.getElementById('new-profile-name').addEventListener('keypress', (e) => { document.getElementById('new-profile-name').addEventListener('keypress', (e) => {
@@ -107,6 +124,12 @@ class LightingController {
document.getElementById('profiles-modal').addEventListener('click', (e) => { document.getElementById('profiles-modal').addEventListener('click', (e) => {
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal'); if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
}); });
document.getElementById('presets-modal').addEventListener('click', (e) => {
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
});
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
});
} }
renderTabs() { renderTabs() {
@@ -125,6 +148,12 @@ class LightingController {
}); });
} }
toggleLeftPanel() {
const leftPanel = document.querySelector('.left-panel');
if (!leftPanel) return;
leftPanel.classList.toggle('collapsed');
}
async selectTab(tabName) { async selectTab(tabName) {
if (!this.state.lights[tabName]) return; if (!this.state.lights[tabName]) return;
@@ -169,25 +198,228 @@ class LightingController {
document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10; document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10;
} }
// Render patterns // Render presets
this.renderPatterns(tabName, pattern); this.renderPresets(tabName);
} }
renderPatterns(tabName, activePattern) { renderPresets(tabName) {
const patternsList = document.getElementById('patterns-list'); const presetsList = document.getElementById('presets-list-tab');
patternsList.innerHTML = ''; presetsList.innerHTML = '';
Object.keys(this.state.patterns).forEach(patternName => { const presets = this.state.presets || {};
const button = document.createElement('button'); const presetNames = Object.keys(presets);
button.className = 'pattern-button';
button.textContent = patternName; // Get current tab's settings for comparison
if (patternName === activePattern) { const currentSettings = this.getCurrentTabSettings(tabName);
button.classList.add('active');
// Always include "on" and "off" presets
const defaultPresets = {
'on': {
pattern: 'on',
colors: ['#FFFFFF'],
brightness: 255,
delay: 100,
n1: 10,
n2: 10,
n3: 10,
n4: 10
},
'off': {
pattern: 'off',
colors: ['#000000'],
brightness: 0,
delay: 100,
n1: 10,
n2: 10,
n3: 10,
n4: 10
} }
button.addEventListener('click', () => this.setPattern(tabName, patternName)); };
patternsList.appendChild(button);
// Create a combined list with default presets first, then user presets
const allPresets = { ...defaultPresets, ...presets };
const allPresetNames = ['on', 'off', ...presetNames.filter(name => name !== 'on' && name !== 'off')];
allPresetNames.forEach(presetName => {
const preset = allPresets[presetName];
const presetButton = document.createElement('button');
presetButton.className = 'pattern-button';
// Check if this preset matches the current tab's settings
const isActive = this.presetMatchesSettings(preset, currentSettings);
if (isActive) {
presetButton.classList.add('active');
}
// Mark default presets (on/off) with a special indicator
const isDefault = presetName === 'on' || presetName === 'off';
if (isDefault) {
presetButton.classList.add('default-preset');
}
// Create preset info display
const presetInfo = document.createElement('div');
presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;';
const presetNameLabel = document.createElement('span');
presetNameLabel.textContent = presetName;
presetNameLabel.style.fontWeight = 'bold';
presetNameLabel.style.marginBottom = '0.25rem';
const presetDetails = document.createElement('span');
presetDetails.style.fontSize = '0.85em';
presetDetails.style.color = '#aaa';
presetDetails.textContent = `${preset.pattern}${preset.colors.length} color${preset.colors.length !== 1 ? 's' : ''}`;
presetInfo.appendChild(presetNameLabel);
presetInfo.appendChild(presetDetails);
presetButton.appendChild(presetInfo);
presetButton.addEventListener('click', () => {
if (isDefault && !presets[presetName]) {
// Apply default preset directly
this.applyDefaultPreset(tabName, presetName, preset);
} else {
// Apply regular preset
this.applyPresetToTab(tabName, presetName);
}
});
presetsList.appendChild(presetButton);
}); });
} }
async applyDefaultPreset(tabName, presetName, preset) {
// Apply default preset by setting pattern and parameters directly
try {
// First set the pattern
const patternResponse = await fetch('/api/pattern', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: tabName,
pattern: preset.pattern,
delay: preset.delay,
colors: preset.colors,
n1: preset.n1,
n2: preset.n2,
n3: preset.n3,
n4: preset.n4
})
});
if (patternResponse.ok) {
// Then update brightness
await fetch('/api/parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: tabName,
brightness: preset.brightness
})
});
// Reload state and tab content
await this.loadState();
await this.loadTabContent(tabName);
} else {
const error = await patternResponse.json();
alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to apply default preset:', error);
alert('Failed to apply preset');
}
}
getCurrentTabSettings(tabName) {
if (!this.state.lights[tabName]) {
return null;
}
const light = this.state.lights[tabName];
const settings = light.settings;
const pattern = settings.pattern || 'on';
const patternSettings = this.getPatternSettings(tabName, pattern);
return {
pattern: pattern,
brightness: settings.brightness || 127,
delay: patternSettings.delay || 100,
colors: patternSettings.colors || ['#000000'],
n1: patternSettings.n1 || 10,
n2: patternSettings.n2 || 10,
n3: patternSettings.n3 || 10,
n4: patternSettings.n4 || 10
};
}
presetMatchesSettings(preset, currentSettings) {
if (!currentSettings) {
return false;
}
// Compare all settings
if (preset.pattern !== currentSettings.pattern) {
return false;
}
if (preset.brightness !== currentSettings.brightness) {
return false;
}
if (preset.delay !== currentSettings.delay) {
return false;
}
// Compare n values
for (let i = 1; i <= 4; i++) {
if (preset[`n${i}`] !== currentSettings[`n${i}`]) {
return false;
}
}
// Compare colors (order matters for presets)
if (preset.colors.length !== currentSettings.colors.length) {
return false;
}
for (let i = 0; i < preset.colors.length; i++) {
// Normalize color format (uppercase, no spaces)
const presetColor = preset.colors[i].toUpperCase().trim();
const currentColor = currentSettings.colors[i].toUpperCase().trim();
if (presetColor !== currentColor) {
return false;
}
}
return true;
}
async applyPresetToTab(tabName, presetName) {
try {
const response = await fetch(`/api/presets/${presetName}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tab_name: tabName
})
});
if (response.ok) {
// Reload state and tab content to reflect changes
await this.loadState();
await this.loadTabContent(tabName);
} else {
const error = await response.json();
alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to apply preset:', error);
alert('Failed to apply preset');
}
}
renderColorPalette(tabName, colors) { renderColorPalette(tabName, colors) {
const palette = document.getElementById('color-palette'); const palette = document.getElementById('color-palette');
@@ -1056,6 +1288,356 @@ class LightingController {
} }
} }
async showPresets() {
const modal = document.getElementById('presets-modal');
modal.classList.add('active');
await this.loadPresets();
}
async loadPresets() {
try {
const response = await fetch('/api/presets');
const data = await response.json();
this.state.presets = data.presets || {};
const presetsList = document.getElementById('presets-list');
presetsList.innerHTML = '';
const presetNames = Object.keys(this.state.presets);
if (presetNames.length === 0) {
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found. Create one to get started.</p>';
} else {
presetNames.forEach(presetName => {
const preset = this.state.presets[presetName];
const presetItem = document.createElement('div');
presetItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;';
const presetInfo = document.createElement('div');
presetInfo.style.cssText = 'display: flex; flex-direction: column; gap: 0.25rem; flex: 1;';
const presetNameLabel = document.createElement('span');
presetNameLabel.textContent = presetName;
presetNameLabel.style.fontWeight = 'bold';
const presetDetails = document.createElement('span');
presetDetails.style.fontSize = '0.9em';
presetDetails.style.color = '#aaa';
presetDetails.textContent = `Pattern: ${preset.pattern} | Brightness: ${preset.brightness} | Delay: ${preset.delay}ms | Colors: ${preset.colors.length}`;
presetInfo.appendChild(presetNameLabel);
presetInfo.appendChild(presetDetails);
const actionsContainer = document.createElement('div');
actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;';
const applyButton = document.createElement('button');
applyButton.className = 'btn btn-small btn-primary';
applyButton.textContent = 'Apply';
applyButton.addEventListener('click', () => this.applyPreset(presetName));
const editButton = document.createElement('button');
editButton.className = 'btn btn-small';
editButton.textContent = 'Edit';
editButton.addEventListener('click', () => this.showPresetEditor(presetName));
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-small btn-danger';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => this.deletePreset(presetName));
actionsContainer.appendChild(applyButton);
actionsContainer.appendChild(editButton);
actionsContainer.appendChild(deleteButton);
presetItem.appendChild(presetInfo);
presetItem.appendChild(actionsContainer);
presetsList.appendChild(presetItem);
});
}
} catch (error) {
console.error('Failed to load presets:', error);
alert('Failed to load presets');
}
}
async applyPreset(presetName) {
if (!this.currentTab) {
alert('Please select a tab first');
return;
}
try {
const response = await fetch(`/api/presets/${presetName}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tab_name: this.currentTab
})
});
if (response.ok) {
await this.loadState();
await this.loadTabContent(this.currentTab);
this.hideModal('presets-modal');
} else {
const error = await response.json();
alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to apply preset:', error);
alert('Failed to apply preset');
}
}
async deletePreset(presetName) {
if (!confirm(`Delete preset '${presetName}'? This cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/presets/${presetName}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadPresets();
} else {
const error = await response.json();
alert(`Failed to delete preset: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to delete preset:', error);
alert('Failed to delete preset');
}
}
showPresetEditor(presetName = null) {
const modal = document.getElementById('preset-editor-modal');
const title = document.getElementById('preset-editor-title');
const nameInput = document.getElementById('preset-name-input');
const patternSelect = document.getElementById('preset-pattern-select');
const brightnessSlider = document.getElementById('preset-brightness-slider');
const brightnessValue = document.getElementById('preset-brightness-value');
const delayInput = document.getElementById('preset-delay-input');
const n1Input = document.getElementById('preset-n1-input');
const n2Input = document.getElementById('preset-n2-input');
const n3Input = document.getElementById('preset-n3-input');
const n4Input = document.getElementById('preset-n4-input');
// Store editing preset name
this.editingPresetName = presetName;
// Populate pattern select
patternSelect.innerHTML = '';
const patterns = Object.keys(this.state.patterns || {});
patterns.forEach(pattern => {
const option = document.createElement('option');
option.value = pattern;
option.textContent = pattern;
patternSelect.appendChild(option);
});
if (presetName && this.state.presets[presetName]) {
// Editing existing preset
title.textContent = 'Edit Preset';
const preset = this.state.presets[presetName];
nameInput.value = presetName;
nameInput.disabled = false;
patternSelect.value = preset.pattern;
brightnessSlider.value = preset.brightness;
brightnessValue.textContent = preset.brightness;
delayInput.value = preset.delay;
n1Input.value = preset.n1;
n2Input.value = preset.n2;
n3Input.value = preset.n3;
n4Input.value = preset.n4;
this.renderPresetColors(preset.colors);
} else {
// Creating new preset
title.textContent = 'Create Preset';
nameInput.value = '';
nameInput.disabled = false;
patternSelect.value = patterns[0] || 'on';
brightnessSlider.value = 127;
brightnessValue.textContent = 127;
delayInput.value = 100;
n1Input.value = 10;
n2Input.value = 10;
n3Input.value = 10;
n4Input.value = 10;
this.renderPresetColors(['#000000']);
}
this.selectedPresetColorIndex = 0;
modal.classList.add('active');
}
renderPresetColors(colors) {
const container = document.getElementById('preset-colors-container');
container.innerHTML = '';
colors.forEach((color, index) => {
const colorWrapper = document.createElement('div');
colorWrapper.style.cssText = 'position: relative;';
const colorSwatch = document.createElement('div');
colorSwatch.style.cssText = `width: 50px; height: 50px; background-color: ${color}; border: 2px solid ${index === this.selectedPresetColorIndex ? '#FFD700' : '#4a4a4a'}; border-radius: 4px; cursor: pointer; position: relative;`;
colorSwatch.dataset.index = index;
colorSwatch.addEventListener('click', () => {
this.selectedPresetColorIndex = index;
this.renderPresetColors(colors);
});
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = color;
colorPicker.style.cssText = 'position: absolute; top: 0; left: 0; width: 50px; height: 50px; opacity: 0; cursor: pointer;';
colorPicker.addEventListener('change', (e) => {
colors[index] = e.target.value;
this.renderPresetColors(colors);
});
colorWrapper.appendChild(colorSwatch);
colorWrapper.appendChild(colorPicker);
container.appendChild(colorWrapper);
});
this.presetColors = colors;
}
addPresetColor() {
const colorInput = document.getElementById('preset-new-color');
const color = colorInput.value;
if (!this.presetColors) {
this.presetColors = [];
}
this.presetColors.push(color);
this.selectedPresetColorIndex = this.presetColors.length - 1;
this.renderPresetColors(this.presetColors);
}
removePresetColor() {
if (!this.presetColors || this.presetColors.length === 0) {
return;
}
if (this.selectedPresetColorIndex >= 0 && this.selectedPresetColorIndex < this.presetColors.length) {
this.presetColors.splice(this.selectedPresetColorIndex, 1);
if (this.selectedPresetColorIndex >= this.presetColors.length) {
this.selectedPresetColorIndex = Math.max(0, this.presetColors.length - 1);
}
this.renderPresetColors(this.presetColors);
}
}
loadCurrentTabToPresetEditor() {
if (!this.currentTab || !this.state.lights[this.currentTab]) {
alert('Please select a tab first');
return;
}
const light = this.state.lights[this.currentTab];
const settings = light.settings;
const pattern = settings.pattern || 'on';
// Get pattern-specific settings
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
const patternSelect = document.getElementById('preset-pattern-select');
const brightnessSlider = document.getElementById('preset-brightness-slider');
const brightnessValue = document.getElementById('preset-brightness-value');
const delayInput = document.getElementById('preset-delay-input');
const n1Input = document.getElementById('preset-n1-input');
const n2Input = document.getElementById('preset-n2-input');
const n3Input = document.getElementById('preset-n3-input');
const n4Input = document.getElementById('preset-n4-input');
patternSelect.value = pattern;
brightnessSlider.value = settings.brightness || 127;
brightnessValue.textContent = settings.brightness || 127;
delayInput.value = patternSettings.delay || 100;
n1Input.value = patternSettings.n1 || 10;
n2Input.value = patternSettings.n2 || 10;
n3Input.value = patternSettings.n3 || 10;
n4Input.value = patternSettings.n4 || 10;
this.renderPresetColors(patternSettings.colors || ['#000000']);
}
async savePreset() {
const nameInput = document.getElementById('preset-name-input');
const patternSelect = document.getElementById('preset-pattern-select');
const brightnessSlider = document.getElementById('preset-brightness-slider');
const delayInput = document.getElementById('preset-delay-input');
const n1Input = document.getElementById('preset-n1-input');
const n2Input = document.getElementById('preset-n2-input');
const n3Input = document.getElementById('preset-n3-input');
const n4Input = document.getElementById('preset-n4-input');
const presetName = nameInput.value.trim();
if (!presetName) {
alert('Please enter a preset name');
return;
}
if (!this.presetColors || this.presetColors.length === 0) {
alert('Please add at least one color');
return;
}
const presetData = {
name: presetName,
pattern: patternSelect.value,
brightness: parseInt(brightnessSlider.value),
delay: parseInt(delayInput.value),
n1: parseInt(n1Input.value),
n2: parseInt(n2Input.value),
n3: parseInt(n3Input.value),
n4: parseInt(n4Input.value),
colors: this.presetColors
};
try {
let response;
if (this.editingPresetName) {
// Update existing preset
response = await fetch(`/api/presets/${this.editingPresetName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(presetData)
});
} else {
// Create new preset
response = await fetch('/api/presets', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(presetData)
});
}
if (response.ok) {
await this.loadPresets();
this.hideModal('preset-editor-modal');
this.editingPresetName = null;
} else {
const error = await response.json();
alert(`Failed to save preset: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to save preset:', error);
alert('Failed to save preset');
}
}
hideModal(modalId) { hideModal(modalId) {
document.getElementById(modalId).classList.remove('active'); document.getElementById(modalId).classList.remove('active');
} }

View File

@@ -153,6 +153,37 @@ header h1 {
font-size: 0.9rem; 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 { .controls-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -310,6 +341,7 @@ header h1 {
} }
.patterns-section, .patterns-section,
.presets-section,
.color-palette-section { .color-palette-section {
background-color: #1a1a1a; background-color: #1a1a1a;
border: 2px solid #4a4a4a; border: 2px solid #4a4a4a;
@@ -318,6 +350,7 @@ header h1 {
} }
.patterns-section h3, .patterns-section h3,
.presets-section h3,
.color-palette-section h3 { .color-palette-section h3 {
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 1.1rem; font-size: 1.1rem;
@@ -329,6 +362,12 @@ header h1 {
gap: 0.5rem; gap: 0.5rem;
} }
.presets-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.pattern-button { .pattern-button {
padding: 0.75rem; padding: 0.75rem;
background-color: #3a3a3a; background-color: #3a3a3a;
@@ -350,6 +389,10 @@ header h1 {
color: white; color: white;
} }
.pattern-button.default-preset {
border: 2px solid #6a5acd;
}
.color-palette { .color-palette {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lighting Controller</title> <title>Lighting Controller</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
@@ -15,6 +15,7 @@
<button id="edit-tab-btn" class="btn btn-secondary">Edit 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="delete-tab-btn" class="btn btn-danger">Delete Tab</button>
<button id="color-palette-btn" class="btn btn-secondary">Color Palette</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> <button id="profiles-btn" class="btn btn-secondary">Profiles</button>
</div> </div>
</header> </header>
@@ -26,60 +27,65 @@
<div id="tab-content" class="tab-content"> <div id="tab-content" class="tab-content">
<div class="left-panel"> <div class="left-panel">
<div class="ids-display"> <div class="left-panel-header">
<label>IDs: </label> <div class="ids-display">
<span id="current-ids"></span> <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>
<div class="color-palette-section"> <div class="left-panel-body">
<h3>Color Palette</h3> <div class="color-palette-section">
<div id="color-palette" class="color-palette"></div> <h3>Color Palette</h3>
<div class="palette-actions"> <div id="color-palette" class="color-palette"></div>
<button id="add-color-btn" class="btn btn-small">Add Color</button> <div class="palette-actions">
<button id="remove-color-btn" class="btn btn-small">Remove Selected</button> <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>
</div>
<div class="controls-section"> <div class="controls-section">
<div class="control-group"> <div class="control-group">
<label for="brightness-slider">Brightness:</label> <label for="brightness-slider">Brightness:</label>
<input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider"> <input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider">
<span id="brightness-value" class="slider-value">127</span> <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>
<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"> <div class="n-params-section">
<h3>N Parameters</h3> <h3>N Parameters</h3>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
<label for="n1-input">n1:</label> <label for="n1-input">n1:</label>
<input type="number" id="n1-input" min="0" max="255" value="10" class="n-input"> <input type="number" id="n1-input" min="0" max="255" value="10" class="n-input">
</div> </div>
<div class="n-param-group"> <div class="n-param-group">
<label for="n2-input">n2:</label> <label for="n2-input">n2:</label>
<input type="number" id="n2-input" min="0" max="255" value="10" class="n-input"> <input type="number" id="n2-input" min="0" max="255" value="10" class="n-input">
</div> </div>
<div class="n-param-group"> <div class="n-param-group">
<label for="n3-input">n3:</label> <label for="n3-input">n3:</label>
<input type="number" id="n3-input" min="0" max="255" value="10" class="n-input"> <input type="number" id="n3-input" min="0" max="255" value="10" class="n-input">
</div> </div>
<div class="n-param-group"> <div class="n-param-group">
<label for="n4-input">n4:</label> <label for="n4-input">n4:</label>
<input type="number" id="n4-input" min="0" max="255" value="10" class="n-input"> <input type="number" id="n4-input" min="0" max="255" value="10" class="n-input">
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="right-panel"> <div class="right-panel">
<div class="patterns-section"> <div class="presets-section">
<h3>Patterns</h3> <h3>Presets</h3>
<div id="patterns-list" class="patterns-list"></div> <div id="presets-list-tab" class="presets-list"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -188,7 +194,86 @@
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='app.js') }}"></script> <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> </body>
</html> </html>

View File

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