Compare commits
2 Commits
c8ae113355
...
c97ca308a7
| Author | SHA1 | Date | |
|---|---|---|---|
| c97ca308a7 | |||
| 5aa500a7fb |
3
Pipfile
3
Pipfile
@@ -12,6 +12,8 @@ python-rtmidi = "*"
|
||||
pyaudio = "*"
|
||||
aubio = "*"
|
||||
websocket-client = "*"
|
||||
flask = "*"
|
||||
flask-cors = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
@@ -21,3 +23,4 @@ python_version = "3.12"
|
||||
[scripts]
|
||||
main = "python src/main.py"
|
||||
dev = 'watchfiles "python src/main.py" src'
|
||||
web = "python run_web.py"
|
||||
|
||||
@@ -1,55 +1,9 @@
|
||||
{
|
||||
"tab_password": "",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"lights": {
|
||||
"ring1": {
|
||||
"test": {
|
||||
"names": [
|
||||
"dj"
|
||||
"1"
|
||||
],
|
||||
"settings": {
|
||||
"pattern": "on",
|
||||
@@ -62,52 +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_order": [
|
||||
"ring1",
|
||||
"ring2"
|
||||
"test"
|
||||
]
|
||||
}
|
||||
17
run_web.py
Executable file
17
run_web.py
Executable 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...")
|
||||
print("Open http://localhost:5000 in your browser")
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
487
src/flask_app.py
Normal file
487
src/flask_app.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
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]
|
||||
return {
|
||||
"colors": pattern_settings.get("colors", ["#000000"]),
|
||||
"delay": pattern_settings.get("delay", 100),
|
||||
"n1": pattern_settings.get("n1", 10),
|
||||
"n2": pattern_settings.get("n2", 10),
|
||||
"n3": pattern_settings.get("n3", 10),
|
||||
"n4": pattern_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 not current_profile:
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
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."""
|
||||
return jsonify({
|
||||
"lights": settings.get("lights", {}),
|
||||
"patterns": settings.get("patterns", {}),
|
||||
"tab_order": settings.get("tab_order", [])
|
||||
})
|
||||
|
||||
|
||||
@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", "")
|
||||
})
|
||||
|
||||
|
||||
@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": []
|
||||
}
|
||||
|
||||
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=['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
|
||||
|
||||
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})
|
||||
|
||||
|
||||
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)
|
||||
|
||||
1054
src/main_textual.py
Normal file
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
2135
src/main_tkinter.py.bak
Normal file
File diff suppressed because it is too large
Load Diff
571
static/app.js
Normal file
571
static/app.js
Normal file
@@ -0,0 +1,571 @@
|
||||
// Lighting Controller Web App
|
||||
class LightingController {
|
||||
constructor() {
|
||||
this.currentTab = null;
|
||||
this.state = {
|
||||
lights: {},
|
||||
patterns: {},
|
||||
tab_order: []
|
||||
};
|
||||
this.selectedColorIndex = 0;
|
||||
this.updateTimeouts = {};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadState();
|
||||
this.setupEventListeners();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async loadState() {
|
||||
try {
|
||||
const response = await fetch('/api/state');
|
||||
const data = await response.json();
|
||||
this.state = data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Tab management
|
||||
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
|
||||
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
|
||||
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
|
||||
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
|
||||
|
||||
// Modal actions
|
||||
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
|
||||
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
|
||||
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
|
||||
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
|
||||
|
||||
// Brightness and delay sliders
|
||||
document.getElementById('brightness-slider').addEventListener('input', (e) => {
|
||||
document.getElementById('brightness-value').textContent = e.target.value;
|
||||
this.debounceUpdate('brightness', () => this.updateBrightness());
|
||||
});
|
||||
document.getElementById('delay-slider').addEventListener('input', (e) => {
|
||||
this.updateDelayValue(e.target.value);
|
||||
this.debounceUpdate('delay', () => this.updateDelay());
|
||||
});
|
||||
|
||||
// N parameters
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
document.getElementById(`n${i}-input`).addEventListener('input', (e) => {
|
||||
this.debounceUpdate('nparams', () => this.updateNParams());
|
||||
});
|
||||
}
|
||||
|
||||
// Color palette
|
||||
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
|
||||
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
|
||||
|
||||
// Close modals on outside click
|
||||
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
|
||||
});
|
||||
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
|
||||
});
|
||||
}
|
||||
|
||||
renderTabs() {
|
||||
const tabsList = document.getElementById('tabs-list');
|
||||
tabsList.innerHTML = '';
|
||||
|
||||
this.state.tab_order.forEach(tabName => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.className = 'tab-button';
|
||||
tabButton.textContent = tabName;
|
||||
tabButton.addEventListener('click', () => this.selectTab(tabName));
|
||||
if (tabName === this.currentTab) {
|
||||
tabButton.classList.add('active');
|
||||
}
|
||||
tabsList.appendChild(tabButton);
|
||||
});
|
||||
}
|
||||
|
||||
async selectTab(tabName) {
|
||||
if (!this.state.lights[tabName]) return;
|
||||
|
||||
this.currentTab = tabName;
|
||||
this.renderTabs();
|
||||
await this.loadTabContent(tabName);
|
||||
}
|
||||
|
||||
async loadTabContent(tabName) {
|
||||
const light = this.state.lights[tabName];
|
||||
if (!light) {
|
||||
return;
|
||||
}
|
||||
const settings = light.settings;
|
||||
const pattern = settings.pattern || 'on';
|
||||
|
||||
// Get pattern-specific settings
|
||||
const patternSettings = this.getPatternSettings(tabName, pattern);
|
||||
|
||||
// Update IDs display
|
||||
document.getElementById('current-ids').textContent = light.names.join(', ');
|
||||
|
||||
// Colors are handled by the color palette with individual color pickers
|
||||
|
||||
// Update brightness slider
|
||||
const brightness = settings.brightness || 127;
|
||||
document.getElementById('brightness-slider').value = brightness;
|
||||
document.getElementById('brightness-value').textContent = brightness;
|
||||
|
||||
// Update delay slider
|
||||
const patternConfig = this.state.patterns[pattern] || {};
|
||||
const minDelay = patternConfig.min_delay || 10;
|
||||
const maxDelay = patternConfig.max_delay || 10000;
|
||||
const delaySliderPos = this.delayToSlider(patternSettings.delay, minDelay, maxDelay);
|
||||
document.getElementById('delay-slider').value = delaySliderPos;
|
||||
this.updateDelayValue(delaySliderPos, minDelay, maxDelay);
|
||||
|
||||
// Update n parameters
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10;
|
||||
}
|
||||
|
||||
// Render patterns
|
||||
this.renderPatterns(tabName, pattern);
|
||||
|
||||
// Render color palette
|
||||
this.renderColorPalette(tabName, colors);
|
||||
}
|
||||
|
||||
renderPatterns(tabName, activePattern) {
|
||||
const patternsList = document.getElementById('patterns-list');
|
||||
patternsList.innerHTML = '';
|
||||
|
||||
Object.keys(this.state.patterns).forEach(patternName => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pattern-button';
|
||||
button.textContent = patternName;
|
||||
if (patternName === activePattern) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
button.addEventListener('click', () => this.setPattern(tabName, patternName));
|
||||
patternsList.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
renderColorPalette(tabName, colors) {
|
||||
const palette = document.getElementById('color-palette');
|
||||
if (!palette) {
|
||||
return;
|
||||
}
|
||||
palette.innerHTML = '';
|
||||
|
||||
colors.forEach((hexColor, index) => {
|
||||
const swatch = document.createElement('div');
|
||||
swatch.className = 'color-swatch';
|
||||
if (index === this.selectedColorIndex) {
|
||||
swatch.classList.add('selected');
|
||||
}
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'color-swatch-preview';
|
||||
preview.style.backgroundColor = hexColor;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'color-swatch-label';
|
||||
label.textContent = `Color ${index + 1}`;
|
||||
|
||||
// Color picker input
|
||||
const colorPicker = document.createElement('input');
|
||||
colorPicker.type = 'color';
|
||||
colorPicker.value = hexColor;
|
||||
colorPicker.className = 'color-picker-input';
|
||||
colorPicker.addEventListener('change', (e) => {
|
||||
const newColor = e.target.value;
|
||||
this.updateColorInPalette(tabName, index, newColor);
|
||||
});
|
||||
|
||||
swatch.appendChild(preview);
|
||||
swatch.appendChild(label);
|
||||
swatch.appendChild(colorPicker);
|
||||
swatch.addEventListener('click', (e) => {
|
||||
// Don't trigger selection if clicking on the color picker
|
||||
if (e.target !== colorPicker && !colorPicker.contains(e.target)) {
|
||||
this.selectColor(tabName, index, hexColor);
|
||||
}
|
||||
});
|
||||
|
||||
palette.appendChild(swatch);
|
||||
});
|
||||
}
|
||||
|
||||
getPatternSettings(tabName, patternName) {
|
||||
const light = this.state.lights[tabName];
|
||||
if (!light) return { colors: ['#000000'], delay: 100, n1: 10, n2: 10, n3: 10, n4: 10 };
|
||||
|
||||
const lightSettings = light.settings;
|
||||
if (!lightSettings.patterns) lightSettings.patterns = {};
|
||||
if (!lightSettings.patterns[patternName]) lightSettings.patterns[patternName] = {};
|
||||
|
||||
const patternSettings = lightSettings.patterns[patternName];
|
||||
return {
|
||||
colors: patternSettings.colors || ['#000000'],
|
||||
delay: patternSettings.delay || 100,
|
||||
n1: patternSettings.n1 || 10,
|
||||
n2: patternSettings.n2 || 10,
|
||||
n3: patternSettings.n3 || 10,
|
||||
n4: patternSettings.n4 || 10
|
||||
};
|
||||
}
|
||||
|
||||
async setPattern(tabName, patternName) {
|
||||
const patternSettings = this.getPatternSettings(tabName, patternName);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pattern', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tab_name: tabName,
|
||||
pattern: patternName,
|
||||
delay: patternSettings.delay,
|
||||
colors: patternSettings.colors,
|
||||
n1: patternSettings.n1,
|
||||
n2: patternSettings.n2,
|
||||
n3: patternSettings.n3,
|
||||
n4: patternSettings.n4
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
await this.loadTabContent(tabName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set pattern:', error);
|
||||
}
|
||||
}
|
||||
|
||||
selectColor(tabName, index, hexColor) {
|
||||
this.selectedColorIndex = index;
|
||||
if (this.state.lights[tabName]) {
|
||||
const pattern = this.state.lights[tabName].settings.pattern;
|
||||
const patternSettings = this.getPatternSettings(tabName, pattern);
|
||||
this.renderColorPalette(tabName, patternSettings.colors);
|
||||
}
|
||||
}
|
||||
|
||||
async updateColorInPalette(tabName, index, hexColor) {
|
||||
if (!this.currentTab || tabName !== this.currentTab) return;
|
||||
|
||||
const pattern = this.state.lights[tabName].settings.pattern;
|
||||
const patternSettings = this.getPatternSettings(tabName, pattern);
|
||||
patternSettings.colors[index] = hexColor;
|
||||
|
||||
// Update the preview
|
||||
const palette = document.getElementById('color-palette');
|
||||
if (palette && palette.children[index]) {
|
||||
const preview = palette.children[index].querySelector('.color-swatch-preview');
|
||||
if (preview) {
|
||||
preview.style.backgroundColor = hexColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Send update to backend to persist changes
|
||||
const rgb = this.hexToRgb(hexColor);
|
||||
try {
|
||||
const response = await fetch('/api/parameters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tab_name: this.currentTab,
|
||||
red: rgb.r,
|
||||
green: rgb.g,
|
||||
blue: rgb.b,
|
||||
color_index: index
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Reload state to ensure persistence
|
||||
await this.loadState();
|
||||
// Re-render palette to reflect persisted state
|
||||
const updatedPatternSettings = this.getPatternSettings(tabName, pattern);
|
||||
this.renderColorPalette(tabName, updatedPatternSettings.colors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update color:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async updateBrightness() {
|
||||
if (!this.currentTab) return;
|
||||
|
||||
const brightness = parseInt(document.getElementById('brightness-slider').value) || 0;
|
||||
|
||||
try {
|
||||
await fetch('/api/parameters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tab_name: this.currentTab,
|
||||
brightness: brightness
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update brightness:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDelay() {
|
||||
if (!this.currentTab) return;
|
||||
|
||||
const sliderValue = parseInt(document.getElementById('delay-slider').value);
|
||||
const pattern = this.state.lights[this.currentTab].settings.pattern;
|
||||
const patternConfig = this.state.patterns[pattern] || {};
|
||||
const minDelay = patternConfig.min_delay || 10;
|
||||
const maxDelay = patternConfig.max_delay || 10000;
|
||||
|
||||
try {
|
||||
await fetch('/api/parameters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tab_name: this.currentTab,
|
||||
delay_slider: sliderValue
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update delay:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateNParams() {
|
||||
if (!this.currentTab) return;
|
||||
|
||||
const nParams = {};
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
nParams[`n${i}`] = parseInt(document.getElementById(`n${i}-input`).value) || 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/parameters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tab_name: this.currentTab,
|
||||
...nParams
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update n params:', error);
|
||||
}
|
||||
}
|
||||
|
||||
debounceUpdate(key, callback) {
|
||||
if (this.updateTimeouts[key]) {
|
||||
clearTimeout(this.updateTimeouts[key]);
|
||||
}
|
||||
this.updateTimeouts[key] = setTimeout(callback, 100);
|
||||
}
|
||||
|
||||
delayToSlider(delayMs, minDelay = 10, maxDelay = 10000) {
|
||||
if (delayMs <= minDelay) return 0;
|
||||
if (delayMs >= maxDelay) return 1000;
|
||||
if (minDelay === maxDelay) return 0;
|
||||
return Math.floor(1000 * Math.log(delayMs / minDelay) / Math.log(maxDelay / minDelay));
|
||||
}
|
||||
|
||||
sliderToDelay(sliderValue, minDelay = 10, maxDelay = 10000) {
|
||||
if (sliderValue <= 0) return minDelay;
|
||||
if (sliderValue >= 1000) return maxDelay;
|
||||
if (minDelay === maxDelay) return minDelay;
|
||||
return Math.floor(minDelay * Math.pow(maxDelay / minDelay, sliderValue / 1000));
|
||||
}
|
||||
|
||||
updateDelayValue(sliderValue, minDelay, maxDelay) {
|
||||
if (!minDelay || !maxDelay) {
|
||||
const pattern = this.currentTab ? this.state.lights[this.currentTab]?.settings?.pattern : 'on';
|
||||
const patternConfig = this.state.patterns[pattern] || {};
|
||||
minDelay = patternConfig.min_delay || 10;
|
||||
maxDelay = patternConfig.max_delay || 10000;
|
||||
}
|
||||
const delay = this.sliderToDelay(parseInt(sliderValue), minDelay, maxDelay);
|
||||
document.getElementById('delay-value').textContent = `${delay} ms`;
|
||||
}
|
||||
|
||||
hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
rgbToHex(r, g, b) {
|
||||
return `#${[r, g, b].map(x => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('')}`;
|
||||
}
|
||||
|
||||
showAddTabModal() {
|
||||
document.getElementById('new-tab-name').value = '';
|
||||
document.getElementById('new-tab-ids').value = '1';
|
||||
document.getElementById('add-tab-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const name = document.getElementById('new-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('new-tab-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!name) {
|
||||
alert('Tab name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tabs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, ids })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(name);
|
||||
this.hideModal('add-tab-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create tab');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create tab:', error);
|
||||
alert('Failed to create tab');
|
||||
}
|
||||
}
|
||||
|
||||
showEditTabModal() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
return;
|
||||
}
|
||||
|
||||
const light = this.state.lights[this.currentTab];
|
||||
document.getElementById('edit-tab-name').value = this.currentTab;
|
||||
document.getElementById('edit-tab-ids').value = light.names.join(', ');
|
||||
document.getElementById('edit-tab-modal').classList.add('active');
|
||||
}
|
||||
|
||||
async updateTab() {
|
||||
const newName = document.getElementById('edit-tab-name').value.trim();
|
||||
const idsStr = document.getElementById('edit-tab-ids').value.trim();
|
||||
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (!newName) {
|
||||
alert('Tab name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tabs/${this.currentTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName, ids })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
this.selectTab(newName);
|
||||
this.hideModal('edit-tab-modal');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to update tab');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update tab:', error);
|
||||
alert('Failed to update tab');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCurrentTab() {
|
||||
if (!this.currentTab) {
|
||||
alert('Please select a tab first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tabs/${this.currentTab}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await this.loadState();
|
||||
this.renderTabs();
|
||||
if (this.state.tab_order.length > 0) {
|
||||
this.selectTab(this.state.tab_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete tab:', error);
|
||||
alert('Failed to delete tab');
|
||||
}
|
||||
}
|
||||
|
||||
async addColorToPalette() {
|
||||
if (!this.currentTab) return;
|
||||
|
||||
const pattern = this.state.lights[this.currentTab].settings.pattern;
|
||||
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
|
||||
patternSettings.colors.push('#000000');
|
||||
this.selectedColorIndex = patternSettings.colors.length - 1;
|
||||
this.renderColorPalette(this.currentTab, patternSettings.colors);
|
||||
}
|
||||
|
||||
async removeSelectedColor() {
|
||||
if (!this.currentTab) return;
|
||||
|
||||
const pattern = this.state.lights[this.currentTab].settings.pattern;
|
||||
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
|
||||
|
||||
if (patternSettings.colors.length <= 1) {
|
||||
alert('There must be at least one color in the palette');
|
||||
return;
|
||||
}
|
||||
|
||||
patternSettings.colors.splice(this.selectedColorIndex, 1);
|
||||
if (this.selectedColorIndex >= patternSettings.colors.length) {
|
||||
this.selectedColorIndex = patternSettings.colors.length - 1;
|
||||
}
|
||||
this.renderColorPalette(this.currentTab, patternSettings.colors);
|
||||
}
|
||||
|
||||
showProfiles() {
|
||||
alert('Profiles feature coming soon');
|
||||
}
|
||||
|
||||
hideModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize app when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new LightingController();
|
||||
});
|
||||
|
||||
505
static/style.css
Normal file
505
static/style.css
Normal file
@@ -0,0 +1,505 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
|
||||
.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,
|
||||
.color-palette-section {
|
||||
background-color: #1a1a1a;
|
||||
border: 2px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.patterns-section h3,
|
||||
.color-palette-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.patterns-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
120
templates/index.html
Normal file
120
templates/index.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!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="{{ url_for('static', filename='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="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="ids-display">
|
||||
<label>IDs: </label>
|
||||
<span id="current-ids"></span>
|
||||
</div>
|
||||
|
||||
<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 class="right-panel">
|
||||
<div class="patterns-section">
|
||||
<h3>Patterns</h3>
|
||||
<div id="patterns-list" class="patterns-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>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user