Compare commits
21 Commits
ac9fca8d4b
...
764d918d5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,7 +23,7 @@ ENV/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
|
settings.json
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
|
|||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "led-driver"]
|
||||||
|
path = led-driver
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
|
||||||
|
[submodule "led-tool"]
|
||||||
|
path = led-tool
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||||
1
Pipfile
1
Pipfile
@@ -24,3 +24,4 @@ web = "python /home/pi/led-controller/tests/web.py"
|
|||||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||||
install = "pipenv install"
|
install = "pipenv install"
|
||||||
run = "sh -c 'cd src && python main.py'"
|
run = "sh -c 'cd src && python main.py'"
|
||||||
|
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
rm -f /home/pi/led-controller/.cursor/debug.log
|
|
||||||
1
db/device.json
Normal file
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,17 +1 @@
|
|||||||
{
|
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||||
"1": {
|
|
||||||
"name": "Main Group",
|
|
||||||
"devices": [
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "Accent Group",
|
|
||||||
"devices": [
|
|
||||||
"4",
|
|
||||||
"5"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1 @@
|
|||||||
{
|
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
||||||
"1": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#FFFF00",
|
|
||||||
"#FF00FF",
|
|
||||||
"#00FFFF",
|
|
||||||
"#FFFFFF",
|
|
||||||
"#000000"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
277
db/preset.json
277
db/preset.json
@@ -1,276 +1 @@
|
|||||||
{
|
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}}
|
||||||
"1": {
|
|
||||||
"name": "on",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": [
|
|
||||||
"#FFFFFF"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "off",
|
|
||||||
"pattern": "off",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 0,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"name": "rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 2,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"4": {
|
|
||||||
"name": "transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"5": {
|
|
||||||
"name": "chase",
|
|
||||||
"pattern": "chase",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#0000FF"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 200,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 5,
|
|
||||||
"n2": 5,
|
|
||||||
"n3": 1,
|
|
||||||
"n4": 1,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"name": "pulse",
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": [
|
|
||||||
"#00FF00"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 1000,
|
|
||||||
"n2": 500,
|
|
||||||
"n3": 1000,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"name": "circle",
|
|
||||||
"pattern": "circle",
|
|
||||||
"colors": [
|
|
||||||
"#FFA500",
|
|
||||||
"#800080"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 200,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 2,
|
|
||||||
"n2": 10,
|
|
||||||
"n3": 2,
|
|
||||||
"n4": 5,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"8": {
|
|
||||||
"name": "blink",
|
|
||||||
"pattern": "blink",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#FFFF00"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 1000,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"9": {
|
|
||||||
"name": "warm white",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FFF5E6"],
|
|
||||||
"brightness": 200,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"10": {
|
|
||||||
"name": "cool white",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#E6F2FF"],
|
|
||||||
"brightness": 200,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"name": "red",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FF0000"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"name": "blue",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"name": "rainbow slow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 150,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 1,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"14": {
|
|
||||||
"name": "pulse slow",
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": ["#FF6600"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 800,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 2000,
|
|
||||||
"n2": 1000,
|
|
||||||
"n3": 2000,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
},
|
|
||||||
"15": {
|
|
||||||
"name": "blink red green",
|
|
||||||
"pattern": "blink",
|
|
||||||
"colors": ["#FF0000", "#00FF00"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0,
|
|
||||||
"profile_id": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1 @@
|
|||||||
{
|
{"1": {"name": "default", "type": "tabs", "tabs": ["1"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||||
"1": {
|
|
||||||
"name": "default",
|
|
||||||
"type": "tabs",
|
|
||||||
"tabs": [
|
|
||||||
"1"
|
|
||||||
],
|
|
||||||
"scenes": [],
|
|
||||||
"palette_id": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +1 @@
|
|||||||
{
|
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
||||||
"1": {
|
|
||||||
"group_name": "Main Group",
|
|
||||||
"presets": [
|
|
||||||
"1",
|
|
||||||
"2"
|
|
||||||
],
|
|
||||||
"sequence_duration": 3000,
|
|
||||||
"sequence_transition": 500,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"group_name": "Accent Group",
|
|
||||||
"presets": [
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
],
|
|
||||||
"sequence_duration": 2000,
|
|
||||||
"sequence_transition": 300,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
db/tab.json
28
db/tab.json
@@ -1,27 +1 @@
|
|||||||
{
|
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "6", "8"], ["10", "11", "9"], ["12", "1", "13"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "6", "8", "10", "11", "9", "12", "1", "13"]}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}}
|
||||||
"1": {
|
|
||||||
"name": "default",
|
|
||||||
"names": [
|
|
||||||
"1","2","3","4","5","6","7","8"
|
|
||||||
],
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3",
|
|
||||||
"4",
|
|
||||||
"5",
|
|
||||||
"6",
|
|
||||||
"7",
|
|
||||||
"8",
|
|
||||||
"9",
|
|
||||||
"10",
|
|
||||||
"11",
|
|
||||||
"12",
|
|
||||||
"13",
|
|
||||||
"14",
|
|
||||||
"15"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
446
docs/API.md
446
docs/API.md
@@ -1,263 +1,297 @@
|
|||||||
# LED Driver ESPNow API Documentation
|
# LED Controller API
|
||||||
|
|
||||||
This document describes the ESPNow message format for controlling LED driver devices.
|
This document covers:
|
||||||
|
|
||||||
## Message Format
|
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||||
|
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||||
|
|
||||||
All messages are JSON objects sent via ESPNow with the following structure:
|
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||||
|
|
||||||
|
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session and scoping
|
||||||
|
|
||||||
|
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
|
||||||
|
|
||||||
|
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Static pages and assets
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/` | Main UI (`templates/index.html`) |
|
||||||
|
| GET | `/settings` | Settings page (`templates/settings.html`) |
|
||||||
|
| GET | `/favicon.ico` | Empty response (204) |
|
||||||
|
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket: `/ws`
|
||||||
|
|
||||||
|
Connect to **`ws://<host>:<port>/ws`**.
|
||||||
|
|
||||||
|
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||||
|
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||||
|
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API by resource
|
||||||
|
|
||||||
|
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||||
|
|
||||||
|
### Settings — `/settings`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
|
||||||
|
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
|
||||||
|
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||||
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
||||||
|
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). |
|
||||||
|
|
||||||
|
### Profiles — `/profiles`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
|
||||||
|
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
|
||||||
|
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||||
|
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Returns `{ "<id>": { ... } }` with status 201. |
|
||||||
|
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||||
|
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||||
|
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||||
|
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||||
|
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||||
|
|
||||||
|
### Presets — `/presets`
|
||||||
|
|
||||||
|
Scoped to **current profile** in session (see above).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
|
||||||
|
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
|
||||||
|
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
|
||||||
|
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
|
||||||
|
| DELETE | `/presets/<id>` | Delete preset. |
|
||||||
|
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
|
||||||
|
|
||||||
|
**`POST /presets/send` body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"preset_ids": ["1", "2"],
|
||||||
|
"save": true,
|
||||||
|
"default": "1",
|
||||||
|
"destination_mac": "aabbccddeeff"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||||
|
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||||
|
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||||
|
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||||
|
|
||||||
|
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
|
||||||
|
|
||||||
|
### Tabs — `/tabs`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||||
|
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||||
|
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||||
|
| GET | `/tabs/<id>` | Tab JSON. |
|
||||||
|
| PUT | `/tabs/<id>` | Update tab. |
|
||||||
|
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||||
|
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||||
|
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||||
|
|
||||||
|
### Palettes — `/palettes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/palettes` | Map of id → color list. |
|
||||||
|
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
|
||||||
|
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
|
||||||
|
| PUT | `/palettes/<id>` | Update colors (`name` ignored). |
|
||||||
|
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||||
|
|
||||||
|
### Groups — `/groups`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/groups` | All groups. |
|
||||||
|
| GET | `/groups/<id>` | One group. |
|
||||||
|
| POST | `/groups` | Create; optional `name` and fields. |
|
||||||
|
| PUT | `/groups/<id>` | Update. |
|
||||||
|
| DELETE | `/groups/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Scenes — `/scenes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/scenes` | All scenes. |
|
||||||
|
| GET | `/scenes/<id>` | One scene. |
|
||||||
|
| POST | `/scenes` | Create (body JSON stored on scene). |
|
||||||
|
| PUT | `/scenes/<id>` | Update. |
|
||||||
|
| DELETE | `/scenes/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Sequences — `/sequences`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/sequences` | All sequences. |
|
||||||
|
| GET | `/sequences/<id>` | One sequence. |
|
||||||
|
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
|
||||||
|
| PUT | `/sequences/<id>` | Update. |
|
||||||
|
| DELETE | `/sequences/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Patterns — `/patterns`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||||
|
| GET | `/patterns` | All pattern records. |
|
||||||
|
| GET | `/patterns/<id>` | One pattern. |
|
||||||
|
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||||
|
| PUT | `/patterns/<id>` | Update. |
|
||||||
|
| DELETE | `/patterns/<id>` | Delete. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LED driver message format (transport / ESP-NOW)
|
||||||
|
|
||||||
|
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||||
|
|
||||||
|
### Top-level fields
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v": "1",
|
"v": "1",
|
||||||
"presets": { ... },
|
"presets": { },
|
||||||
"select": { ... }
|
"select": { },
|
||||||
|
"save": true,
|
||||||
|
"default": "preset_id",
|
||||||
|
"b": 255
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Version Field
|
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||||
|
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||||
|
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||||
|
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||||
|
- **`default`**: Preset id string to use as startup default on the device.
|
||||||
|
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||||
|
|
||||||
- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored.
|
### Preset object (wire / driver keys)
|
||||||
|
|
||||||
## Presets
|
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
|
||||||
|
|
||||||
Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings.
|
| Key | Meaning | Notes |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
|
||||||
|
| `c` | Colors | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||||
|
| `d` | Delay ms | Default 100 |
|
||||||
|
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||||
|
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||||
|
| `n1`–`n6` | Pattern parameters | See below |
|
||||||
|
|
||||||
### Preset Structure
|
The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
|
||||||
|
|
||||||
```json
|
### Pattern-specific parameters (`n1`–`n6`)
|
||||||
{
|
|
||||||
"presets": {
|
|
||||||
"preset_name": {
|
|
||||||
"pattern": "pattern_type",
|
|
||||||
"colors": ["#RRGGBB", ...],
|
|
||||||
"delay": 100,
|
|
||||||
"brightness": 127,
|
|
||||||
"auto": true,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preset Fields
|
|
||||||
|
|
||||||
- **`pattern`** (required): Pattern type. Options:
|
|
||||||
- `"off"` - Turn off all LEDs
|
|
||||||
- `"on"` - Solid color
|
|
||||||
- `"blink"` - Blinking pattern
|
|
||||||
- `"rainbow"` - Rainbow color cycle
|
|
||||||
- `"pulse"` - Pulse/fade pattern
|
|
||||||
- `"transition"` - Color transition
|
|
||||||
- `"chase"` - Chasing pattern
|
|
||||||
- `"circle"` - Circle loading pattern
|
|
||||||
|
|
||||||
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
|
|
||||||
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
|
|
||||||
- Supports multiple colors for patterns that use them
|
|
||||||
|
|
||||||
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
|
|
||||||
|
|
||||||
- **`brightness`** (optional): Brightness level (0-255). Default: `127`
|
|
||||||
|
|
||||||
- **`auto`** (optional): Auto mode flag. Default: `true`
|
|
||||||
- `true`: Pattern runs continuously
|
|
||||||
- `false`: Pattern advances one step per beat (manual mode)
|
|
||||||
|
|
||||||
- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0`
|
|
||||||
- See pattern-specific documentation below
|
|
||||||
|
|
||||||
### Pattern-Specific Parameters
|
|
||||||
|
|
||||||
#### Rainbow
|
#### Rainbow
|
||||||
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1`
|
- **`n1`**: Step increment on the color wheel per update (default 1).
|
||||||
|
|
||||||
#### Pulse
|
#### Pulse
|
||||||
- **`n1`**: Attack time in milliseconds (fade in)
|
- **`n1`**: Attack (fade in) ms
|
||||||
- **`n2`**: Hold time in milliseconds (full brightness)
|
- **`n2`**: Hold ms
|
||||||
- **`n3`**: Decay time in milliseconds (fade out)
|
- **`n3`**: Decay (fade out) ms
|
||||||
- **`delay`**: Delay time in milliseconds (off between pulses)
|
- **`d`**: Off time between pulses ms
|
||||||
|
|
||||||
#### Transition
|
#### Transition
|
||||||
- **`delay`**: Transition duration in milliseconds
|
- **`d`**: Transition duration ms
|
||||||
|
|
||||||
#### Chase
|
#### Chase
|
||||||
- **`n1`**: Number of LEDs with first color
|
- **`n1`**: LEDs with first color
|
||||||
- **`n2`**: Number of LEDs with second color
|
- **`n2`**: LEDs with second color
|
||||||
- **`n3`**: Movement amount on even steps (can be negative)
|
- **`n3`**: Movement on even steps (may be negative)
|
||||||
- **`n4`**: Movement amount on odd steps (can be negative)
|
- **`n4`**: Movement on odd steps (may be negative)
|
||||||
|
|
||||||
#### Circle
|
#### Circle
|
||||||
- **`n1`**: Head movement rate (LEDs per second)
|
- **`n1`**: Head speed (LEDs/s)
|
||||||
- **`n2`**: Maximum length
|
- **`n2`**: Max length
|
||||||
- **`n3`**: Tail movement rate (LEDs per second)
|
- **`n3`**: Tail speed (LEDs/s)
|
||||||
- **`n4`**: Minimum length
|
- **`n4`**: Min length
|
||||||
|
|
||||||
## Select Messages
|
### Select messages
|
||||||
|
|
||||||
Select messages control which preset is active on which device. The format uses a list to support step synchronization.
|
|
||||||
|
|
||||||
### Select Format
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"select": {
|
"select": {
|
||||||
"device_name": ["preset_name"],
|
"device_name": ["preset_id"],
|
||||||
"device_name2": ["preset_name2", step_value]
|
"other_device": ["preset_id", 10]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Select Fields
|
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
|
||||||
|
- Two elements: explicit **step** for sync.
|
||||||
|
|
||||||
- **`select`**: Object mapping device names to selection lists
|
### Beat and sync behavior
|
||||||
- **Key**: Device name (as configured in device settings)
|
|
||||||
- **Value**: List with one or two elements:
|
|
||||||
- `["preset_name"]` - Select preset (uses default step behavior)
|
|
||||||
- `["preset_name", step]` - Select preset with explicit step value (for synchronization)
|
|
||||||
|
|
||||||
### Step Synchronization
|
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
|
||||||
|
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
|
||||||
|
|
||||||
The step value allows precise synchronization across multiple devices:
|
### Example (compact preset map)
|
||||||
|
|
||||||
- **Without step**: `["preset_name"]`
|
|
||||||
- If switching to different preset: step resets to 0
|
|
||||||
- If selecting "off" pattern: step resets to 0
|
|
||||||
- If selecting same preset (beat): step is preserved, pattern restarts
|
|
||||||
|
|
||||||
- **With step**: `["preset_name", 10]`
|
|
||||||
- Explicitly sets step to the specified value
|
|
||||||
- Useful for synchronizing multiple devices to the same step
|
|
||||||
|
|
||||||
### Beat Functionality
|
|
||||||
|
|
||||||
Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator:
|
|
||||||
|
|
||||||
- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat
|
|
||||||
- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat
|
|
||||||
|
|
||||||
Example beat sequence:
|
|
||||||
```json
|
|
||||||
// Beat 1
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
|
|
||||||
// Beat 2 (same preset = beat)
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
|
|
||||||
// Beat 3
|
|
||||||
{"select": {"device1": ["rainbow_preset"]}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Synchronization
|
|
||||||
|
|
||||||
### Using "off" Pattern
|
|
||||||
|
|
||||||
Selecting the "off" pattern resets the step counter to 0, providing a synchronization point:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["off"],
|
|
||||||
"device2": ["off"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After all devices are "off", switching to a pattern ensures they all start from step 0:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["rainbow_preset"],
|
|
||||||
"device2": ["rainbow_preset"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Step Parameter
|
|
||||||
|
|
||||||
For precise synchronization, use the step parameter:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"select": {
|
|
||||||
"device1": ["rainbow_preset", 10],
|
|
||||||
"device2": ["rainbow_preset", 10],
|
|
||||||
"device3": ["rainbow_preset", 10]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All devices will start at step 10 and advance together on subsequent beats.
|
|
||||||
|
|
||||||
## Complete Example
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v": "1",
|
"v": "1",
|
||||||
|
"save": true,
|
||||||
"presets": {
|
"presets": {
|
||||||
"red_blink": {
|
"1": {
|
||||||
"pattern": "blink",
|
"name": "Red blink",
|
||||||
"colors": ["#FF0000"],
|
"p": "blink",
|
||||||
"delay": 200,
|
"c": ["#FF0000"],
|
||||||
"brightness": 255,
|
"d": 200,
|
||||||
"auto": true
|
"b": 255,
|
||||||
},
|
"a": true,
|
||||||
"rainbow_manual": {
|
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||||
"pattern": "rainbow",
|
|
||||||
"delay": 100,
|
|
||||||
"n1": 2,
|
|
||||||
"auto": false
|
|
||||||
},
|
|
||||||
"pulse_slow": {
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": ["#00FF00"],
|
|
||||||
"delay": 500,
|
|
||||||
"n1": 1000,
|
|
||||||
"n2": 500,
|
|
||||||
"n3": 1000,
|
|
||||||
"auto": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"device1": ["red_blink"],
|
"living-room": ["1"]
|
||||||
"device2": ["rainbow_manual", 0],
|
|
||||||
"device3": ["pulse_slow"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Message Processing
|
---
|
||||||
|
|
||||||
1. **Version Check**: Messages with `v != "1"` are rejected
|
## Processing summary (driver)
|
||||||
2. **Preset Processing**: Presets are created or updated (upsert behavior)
|
|
||||||
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order
|
|
||||||
4. **Selection**: Devices select their assigned preset, optionally with step value
|
|
||||||
|
|
||||||
## Best Practices
|
1. Reject if `v != "1"`.
|
||||||
|
2. Apply optional top-level **`b`** (global brightness).
|
||||||
|
3. For each entry in **`presets`**, normalize colors and upsert preset by id.
|
||||||
|
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||||
|
5. If **`default`** is set, store startup preset id.
|
||||||
|
6. If **`save`** is set, persist presets.
|
||||||
|
|
||||||
1. **Always include version**: Set `"v": "1"` in all messages
|
---
|
||||||
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
|
|
||||||
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
|
|
||||||
4. **Step for precision**: Use step parameter when exact synchronization is required
|
|
||||||
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
|
|
||||||
|
|
||||||
## Error Handling
|
## Error handling (HTTP)
|
||||||
|
|
||||||
- Invalid version: Message is ignored
|
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
|
||||||
- Missing preset: Selection fails, device keeps current preset
|
|
||||||
- Invalid pattern: Selection fails, device keeps current preset
|
---
|
||||||
- Missing colors: Pattern uses default white color
|
|
||||||
- Invalid step: Step value is used as-is (may cause unexpected behavior)
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Colors are automatically converted from hex strings to RGB tuples
|
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
|
||||||
- Color order reordering happens automatically based on device settings
|
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
|
||||||
- Step counter wraps around (0-255 for rainbow, unbounded for others)
|
|
||||||
- Manual mode patterns stop after one step/cycle, waiting for next beat
|
|
||||||
- Auto mode patterns run continuously until changed
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
{
|
{
|
||||||
"grps": [
|
"g":{
|
||||||
{
|
"df": {
|
||||||
"n": "group1",
|
|
||||||
"pt": "on",
|
"pt": "on",
|
||||||
"cl": [
|
"cl": ["#ff0000"],
|
||||||
"000000",
|
"br": 200,
|
||||||
"000000"
|
"n1": 10,
|
||||||
],
|
"n2": 10,
|
||||||
"br": 100,
|
"n3": 10,
|
||||||
"dl": 100,
|
"n4": 10,
|
||||||
"n1": 0,
|
"n5": 10,
|
||||||
"n2": 0,
|
"n6": 10,
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"n": "group2",
|
|
||||||
"pt": "on",
|
|
||||||
"cl": [
|
|
||||||
"000000",
|
|
||||||
"000000"
|
|
||||||
],
|
|
||||||
"br": 100,
|
|
||||||
"dl": 100
|
"dl": 100
|
||||||
|
},
|
||||||
|
"dj": {
|
||||||
|
"pt": "blink",
|
||||||
|
"cl": ["#00ff00"],
|
||||||
|
"dl": 500
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
|
"sv": true,
|
||||||
|
"st": 0
|
||||||
}
|
}
|
||||||
@@ -51,11 +51,14 @@ def ensure_peer(addr):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
print("Starting ESP32 main.py")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if uart.any():
|
if uart.any():
|
||||||
data = uart.read()
|
data = uart.read()
|
||||||
if not data or len(data) < 6:
|
if not data or len(data) < 6:
|
||||||
continue
|
continue
|
||||||
|
print(f"Received data: {data}")
|
||||||
addr = data[:6]
|
addr = data[:6]
|
||||||
payload = data[6:]
|
payload = data[6:]
|
||||||
ensure_peer(addr)
|
ensure_peer(addr)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Install script - runs pipenv install
|
|
||||||
|
|
||||||
pipenv install "$@"
|
|
||||||
1
led-driver
Submodule
1
led-driver
Submodule
Submodule led-driver added at 4c7646b2fe
1
led-tool
Submodule
1
led-tool
Submodule
Submodule led-tool added at 3844aa9d6a
23
msg.json
23
msg.json
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"g":{
|
|
||||||
"df": {
|
|
||||||
"pt": "on",
|
|
||||||
"cl": ["#ff0000"],
|
|
||||||
"br": 200,
|
|
||||||
"n1": 10,
|
|
||||||
"n2": 10,
|
|
||||||
"n3": 10,
|
|
||||||
"n4": 10,
|
|
||||||
"n5": 10,
|
|
||||||
"n6": 10,
|
|
||||||
"dl": 100
|
|
||||||
},
|
|
||||||
"dj": {
|
|
||||||
"pt": "blink",
|
|
||||||
"cl": ["#00ff00"],
|
|
||||||
"dl": 500
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv": true,
|
|
||||||
"st": 0
|
|
||||||
}
|
|
||||||
173
run_web.py
173
run_web.py
@@ -1,173 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Local development web server - imports and runs main.py with port 5000
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Add src and lib to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
|
|
||||||
|
|
||||||
# Import the main module
|
|
||||||
from src import main as main_module
|
|
||||||
|
|
||||||
# Override the port in the main function
|
|
||||||
async def run_local():
|
|
||||||
"""Run main with port 5000 for local development."""
|
|
||||||
from settings import Settings
|
|
||||||
import gc
|
|
||||||
|
|
||||||
# Mock MicroPython modules for local development
|
|
||||||
class MockMachine:
|
|
||||||
class WDT:
|
|
||||||
def __init__(self, timeout):
|
|
||||||
pass
|
|
||||||
def feed(self):
|
|
||||||
pass
|
|
||||||
import sys as sys_module
|
|
||||||
sys_module.modules['machine'] = MockMachine()
|
|
||||||
|
|
||||||
class MockESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
self.active_value = False
|
|
||||||
self.peers = []
|
|
||||||
def active(self, value):
|
|
||||||
self.active_value = value
|
|
||||||
print(f"[MOCK] ESPNow active: {value}")
|
|
||||||
def add_peer(self, peer):
|
|
||||||
self.peers.append(peer)
|
|
||||||
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
|
|
||||||
async def asend(self, peer, data):
|
|
||||||
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
|
|
||||||
|
|
||||||
class MockAIOESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
def active(self, value):
|
|
||||||
return MockESPNow()
|
|
||||||
def add_peer(self, peer):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class MockNetwork:
|
|
||||||
class WLAN:
|
|
||||||
def __init__(self, interface):
|
|
||||||
self.interface = interface
|
|
||||||
def active(self, value):
|
|
||||||
print(f"[MOCK] WLAN({self.interface}) active: {value}")
|
|
||||||
STA_IF = 0
|
|
||||||
|
|
||||||
# Replace MicroPython modules with mocks
|
|
||||||
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
|
|
||||||
sys_module.modules['network'] = MockNetwork()
|
|
||||||
|
|
||||||
# Mock gc if needed
|
|
||||||
if not hasattr(gc, 'collect'):
|
|
||||||
class MockGC:
|
|
||||||
def collect(self):
|
|
||||||
pass
|
|
||||||
gc = MockGC()
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
print("Starting LED Controller Web Server (Local Development)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Mock network
|
|
||||||
import network
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
|
||||||
|
|
||||||
# Mock ESPNow
|
|
||||||
import aioespnow
|
|
||||||
e = aioespnow.AIOESPNow()
|
|
||||||
e.active(True)
|
|
||||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
|
||||||
|
|
||||||
from microdot import Microdot, send_file
|
|
||||||
from microdot.websocket import with_websocket
|
|
||||||
|
|
||||||
from microdot.session import Session
|
|
||||||
|
|
||||||
import controllers.preset as preset
|
|
||||||
import controllers.profile as profile
|
|
||||||
import controllers.group as group
|
|
||||||
import controllers.sequence as sequence
|
|
||||||
import controllers.tab as tab
|
|
||||||
import controllers.palette as palette
|
|
||||||
import controllers.scene as scene
|
|
||||||
import controllers.pattern as pattern
|
|
||||||
import controllers.settings as settings_controller
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
|
|
||||||
# Initialize sessions with a secret key from settings
|
|
||||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
|
||||||
Session(app, secret_key=secret_key)
|
|
||||||
|
|
||||||
# Mount model controllers as subroutes
|
|
||||||
app.mount(preset.controller, '/presets')
|
|
||||||
app.mount(profile.controller, '/profiles')
|
|
||||||
app.mount(group.controller, '/groups')
|
|
||||||
app.mount(sequence.controller, '/sequences')
|
|
||||||
app.mount(tab.controller, '/tabs')
|
|
||||||
app.mount(palette.controller, '/palettes')
|
|
||||||
app.mount(scene.controller, '/scenes')
|
|
||||||
app.mount(pattern.controller, '/patterns')
|
|
||||||
app.mount(settings_controller.controller, '/settings')
|
|
||||||
|
|
||||||
# Serve index.html at root
|
|
||||||
@app.route('/')
|
|
||||||
def index(request):
|
|
||||||
"""Serve the main web UI."""
|
|
||||||
return send_file('src/templates/index.html')
|
|
||||||
|
|
||||||
# Serve settings page
|
|
||||||
@app.route('/settings')
|
|
||||||
def settings_page(request):
|
|
||||||
"""Serve the settings page."""
|
|
||||||
return send_file('src/templates/settings.html')
|
|
||||||
|
|
||||||
# Favicon: avoid 404 in browser console (no file needed)
|
|
||||||
@app.route('/favicon.ico')
|
|
||||||
def favicon(request):
|
|
||||||
return '', 204
|
|
||||||
|
|
||||||
# Static file route
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
"""Serve static files."""
|
|
||||||
if '..' in path:
|
|
||||||
return 'Not found', 404
|
|
||||||
return send_file('src/static/' + path)
|
|
||||||
|
|
||||||
@app.route('/ws')
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if data:
|
|
||||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
|
||||||
print(data)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Use port 5000 for local development
|
|
||||||
port = 5000
|
|
||||||
print(f"Starting server on http://0.0.0.0:{port}")
|
|
||||||
print(f"Open http://localhost:{port} in your browser")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await app.start_server(host="0.0.0.0", port=port, debug=True)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down server...")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Change to project root
|
|
||||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
# Override settings path for local development
|
|
||||||
import settings as settings_module
|
|
||||||
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
|
|
||||||
|
|
||||||
asyncio.run(run_local())
|
|
||||||
4
scripts/cp-esp32-main.sh
Normal file
4
scripts/cp-esp32-main.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||||
20
scripts/install-boot-service.sh
Executable file
20
scripts/install-boot-service.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install systemd service so LED controller starts at boot.
|
||||||
|
# Run once: sudo scripts/install-boot-service.sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO="$(pwd)"
|
||||||
|
SERVICE_NAME="led-controller.service"
|
||||||
|
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
|
||||||
|
if [ ! -f "scripts/led-controller.service" ]; then
|
||||||
|
echo "Run this script from the repo root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x scripts/start.sh
|
||||||
|
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE_NAME"
|
||||||
|
echo "Installed and enabled $SERVICE_NAME"
|
||||||
|
echo "Start now: sudo systemctl start $SERVICE_NAME"
|
||||||
|
echo "Status: sudo systemctl status $SERVICE_NAME"
|
||||||
|
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
17
scripts/led-controller.service
Normal file
17
scripts/led-controller.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=LED Controller web server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/home/pi/led-controller
|
||||||
|
Environment=PORT=80
|
||||||
|
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
5
scripts/start.sh
Executable file
5
scripts/start.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the LED controller web server (port 80 by default).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PORT="${PORT:-80}"
|
||||||
|
pipenv run run
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
# Connect to the WebSocket
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.connect(('192.168.4.1', 80))
|
|
||||||
|
|
||||||
# Send HTTP WebSocket upgrade request
|
|
||||||
key = base64.b64encode(b'test-nonce').decode('utf-8')
|
|
||||||
request = f'''GET /ws HTTP/1.1\r
|
|
||||||
Host: 192.168.4.1\r
|
|
||||||
Upgrade: websocket\r
|
|
||||||
Connection: Upgrade\r
|
|
||||||
Sec-WebSocket-Key: {key}\r
|
|
||||||
Sec-WebSocket-Version: 13\r
|
|
||||||
\r
|
|
||||||
'''
|
|
||||||
s.send(request.encode())
|
|
||||||
|
|
||||||
# Read upgrade response
|
|
||||||
response = s.recv(4096)
|
|
||||||
print(response.decode())
|
|
||||||
|
|
||||||
# Send WebSocket TEXT frame with empty JSON '{}'
|
|
||||||
payload = b'{}'
|
|
||||||
mask = b'\x12\x34\x56\x78'
|
|
||||||
payload_masked = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
|
|
||||||
|
|
||||||
frame = struct.pack('BB', 0x81, 0x80 | len(payload))
|
|
||||||
frame += mask
|
|
||||||
frame += payload_masked
|
|
||||||
|
|
||||||
s.send(frame)
|
|
||||||
print("Sent empty JSON to WebSocket")
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
|
||||||
68
src/controllers/device.py
Normal file
68
src/controllers/device.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.device import Device
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
devices = Device()
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
async def list_devices(request):
|
||||||
|
"""List all devices."""
|
||||||
|
devices_data = {}
|
||||||
|
for dev_id in devices.list():
|
||||||
|
d = devices.read(dev_id)
|
||||||
|
if d:
|
||||||
|
devices_data[dev_id] = d
|
||||||
|
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
async def get_device(request, id):
|
||||||
|
"""Get a device by ID."""
|
||||||
|
dev = devices.read(id)
|
||||||
|
if dev:
|
||||||
|
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("")
|
||||||
|
async def create_device(request):
|
||||||
|
"""Create a new device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
address = data.get("address")
|
||||||
|
default_pattern = data.get("default_pattern")
|
||||||
|
tabs = data.get("tabs")
|
||||||
|
if isinstance(tabs, list):
|
||||||
|
tabs = [str(t) for t in tabs]
|
||||||
|
else:
|
||||||
|
tabs = []
|
||||||
|
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/<id>")
|
||||||
|
async def update_device(request, id):
|
||||||
|
"""Update a device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
if "tabs" in data and isinstance(data["tabs"], list):
|
||||||
|
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||||
|
if devices.update(id, data):
|
||||||
|
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.delete("/<id>")
|
||||||
|
async def delete_device(request, id):
|
||||||
|
"""Delete a device."""
|
||||||
|
if devices.delete(id):
|
||||||
|
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
@@ -17,9 +17,9 @@ async def list_palettes(request):
|
|||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
async def get_palette(request, id):
|
async def get_palette(request, id):
|
||||||
"""Get a specific palette by ID."""
|
"""Get a specific palette by ID."""
|
||||||
palette = palettes.read(id)
|
if str(id) in palettes:
|
||||||
if palette:
|
palette = palettes.read(id)
|
||||||
return json.dumps({"colors": palette, "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
@@ -30,11 +30,8 @@ async def create_palette(request):
|
|||||||
colors = data.get("colors", None)
|
colors = data.get("colors", None)
|
||||||
# Palette no longer needs a name; only colors are stored.
|
# Palette no longer needs a name; only colors are stored.
|
||||||
palette_id = palettes.create("", colors)
|
palette_id = palettes.create("", colors)
|
||||||
palette = palettes.read(palette_id) or {}
|
created_colors = palettes.read(palette_id) or []
|
||||||
# Include the ID in the response payload so clients can link it.
|
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||||
palette_with_id = {"id": str(palette_id)}
|
|
||||||
palette_with_id.update(palette)
|
|
||||||
return json.dumps(palette_with_id), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@@ -47,10 +44,8 @@ async def update_palette(request, id):
|
|||||||
if "name" in data:
|
if "name" in data:
|
||||||
data.pop("name", None)
|
data.pop("name", None)
|
||||||
if palettes.update(id, data):
|
if palettes.update(id, data):
|
||||||
palette = palettes.read(id) or {}
|
colors = palettes.read(id) or []
|
||||||
palette_with_id = {"id": str(id)}
|
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||||
palette_with_id.update(palette)
|
|
||||||
return json.dumps(palette_with_id), 200, {'Content-Type': 'application/json'}
|
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ async def list_presets(request, session):
|
|||||||
}
|
}
|
||||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_preset(request, id, session):
|
async def get_preset(request, session, preset_id):
|
||||||
"""Get a specific preset by ID (current profile only)."""
|
"""Get a specific preset by ID (current profile only)."""
|
||||||
preset = presets.read(id)
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||||
@@ -70,12 +70,12 @@ async def create_preset(request, session):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.put('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_preset(request, id, session):
|
async def update_preset(request, session, preset_id):
|
||||||
"""Update an existing preset (current profile only)."""
|
"""Update an existing preset (current profile only)."""
|
||||||
try:
|
try:
|
||||||
preset = presets.read(id)
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
@@ -87,21 +87,36 @@ async def update_preset(request, id, session):
|
|||||||
data = {}
|
data = {}
|
||||||
data = dict(data)
|
data = dict(data)
|
||||||
data["profile_id"] = str(current_profile_id)
|
data["profile_id"] = str(current_profile_id)
|
||||||
if presets.update(id, data):
|
if presets.update(preset_id, data):
|
||||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def delete_preset(request, id, session):
|
async def delete_preset(request, *args, **kwargs):
|
||||||
"""Delete a preset (current profile only)."""
|
"""Delete a preset (current profile only)."""
|
||||||
preset = presets.read(id)
|
# Be tolerant of wrapper/arg-order variations.
|
||||||
|
session = None
|
||||||
|
preset_id = None
|
||||||
|
if len(args) > 0:
|
||||||
|
session = args[0]
|
||||||
|
if len(args) > 1:
|
||||||
|
preset_id = args[1]
|
||||||
|
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||||
|
session = kwargs.get('session')
|
||||||
|
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||||
|
preset_id = kwargs.get('preset_id')
|
||||||
|
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||||
|
preset_id = kwargs.get('id')
|
||||||
|
if preset_id is None:
|
||||||
|
return json.dumps({"error": "Preset ID is required"}), 400
|
||||||
|
preset = presets.read(preset_id)
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
if presets.delete(id):
|
if presets.delete(preset_id):
|
||||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,117 @@ async def apply_profile(request, session, id):
|
|||||||
async def create_profile(request):
|
async def create_profile(request):
|
||||||
"""Create a new profile."""
|
"""Create a new profile."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = dict(request.json or {})
|
||||||
name = data.get("name", "")
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_tab", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_tab = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_tab", None)
|
||||||
profile_id = profiles.create(name)
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
if data:
|
if data:
|
||||||
profiles.update(profile_id, data)
|
profiles.update(profile_id, data)
|
||||||
|
|
||||||
|
# New profiles always start with a default tab pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
tabs.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_tab:
|
||||||
|
# Seed a DJ-focused tab with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
tabs.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||||
|
|
||||||
profile_data = profiles.read(profile_id)
|
profile_data = profiles.read(profile_id)
|
||||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
54
src/models/device.py
Normal file
54
src/models/device.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_address(addr):
|
||||||
|
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
|
||||||
|
if addr is None:
|
||||||
|
return None
|
||||||
|
s = str(addr).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
addr = _normalize_address(address)
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"address": addr,
|
||||||
|
"default_pattern": default_pattern if default_pattern else None,
|
||||||
|
"tabs": list(tabs) if tabs else [],
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
return self.get(id_str, None)
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
if "address" in data and data["address"] is not None:
|
||||||
|
data = dict(data)
|
||||||
|
data["address"] = _normalize_address(data["address"])
|
||||||
|
self[id_str].update(data)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
self.pop(id_str)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return list(self.keys())
|
||||||
@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const closeButton = document.getElementById('color-palette-close-btn');
|
const closeButton = document.getElementById('color-palette-close-btn');
|
||||||
const paletteContainer = document.getElementById('palette-container');
|
const paletteContainer = document.getElementById('palette-container');
|
||||||
const paletteNewColor = document.getElementById('palette-new-color');
|
const paletteNewColor = document.getElementById('palette-new-color');
|
||||||
const paletteAddButton = document.getElementById('palette-add-color-btn');
|
|
||||||
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
||||||
|
|
||||||
if (!paletteButton || !paletteModal || !paletteContainer) {
|
if (!paletteButton || !paletteModal || !paletteContainer) {
|
||||||
@@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.addEventListener('click', closeModal);
|
closeButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
if (paletteAddButton && paletteNewColor) {
|
if (paletteNewColor) {
|
||||||
paletteAddButton.addEventListener('click', async () => {
|
const addSelectedColor = async () => {
|
||||||
const color = paletteNewColor.value;
|
const color = paletteNewColor.value;
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return;
|
return;
|
||||||
@@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await savePalette([...currentPalette, color]);
|
await savePalette([...currentPalette, color]);
|
||||||
});
|
};
|
||||||
|
// Add when the picker closes (user confirms selection).
|
||||||
|
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||||
}
|
}
|
||||||
paletteModal.addEventListener('click', (event) => {
|
paletteModal.addEventListener('click', (event) => {
|
||||||
if (event.target === paletteModal) {
|
if (event.target === paletteModal) {
|
||||||
|
|||||||
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||||
|
|
||||||
|
const HEX_BOX_COUNT = 12;
|
||||||
|
|
||||||
|
function makeHexAddressBoxes(container) {
|
||||||
|
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'hex-addr-box';
|
||||||
|
input.maxLength = 1;
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.setAttribute('data-index', i);
|
||||||
|
input.setAttribute('inputmode', 'numeric');
|
||||||
|
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
e.target.value = v;
|
||||||
|
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||||
|
e.target.nextElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||||
|
e.target.previousElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('paste', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||||
|
boxes[j].value = pasted[j];
|
||||||
|
}
|
||||||
|
if (pasted.length > 0) {
|
||||||
|
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||||
|
boxes[nextIdx].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddressFromBoxes(container) {
|
||||||
|
if (!container) return '';
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAddressToBoxes(container, addrStr) {
|
||||||
|
if (!container) return;
|
||||||
|
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
boxes.forEach((b, i) => {
|
||||||
|
b.value = s[i] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevicesModal() {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to load devices');
|
||||||
|
const devices = await response.json();
|
||||||
|
renderDevicesList(devices || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadDevicesModal:', e);
|
||||||
|
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevicesList(devices) {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||||
|
if (ids.length === 0) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'muted-text';
|
||||||
|
p.textContent = 'No devices. Create one above.';
|
||||||
|
container.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ids.forEach((devId) => {
|
||||||
|
const dev = devices[devId];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.gap = '0.5rem';
|
||||||
|
row.style.flexWrap = 'wrap';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = (dev && dev.name) || devId;
|
||||||
|
label.style.flex = '1';
|
||||||
|
label.style.minWidth = '100px';
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'muted-text';
|
||||||
|
meta.style.fontSize = '0.85em';
|
||||||
|
const addr = (dev && dev.address) ? dev.address : '—';
|
||||||
|
meta.textContent = `Address: ${addr}`;
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) await loadDevicesModal();
|
||||||
|
else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Delete failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Delete failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(editBtn);
|
||||||
|
row.appendChild(deleteBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDeviceModal(devId, dev) {
|
||||||
|
const modal = document.getElementById('edit-device-modal');
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
if (!modal || !idInput) return;
|
||||||
|
idInput.value = devId;
|
||||||
|
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||||
|
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevice(name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to create device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('createDevice:', e);
|
||||||
|
alert('Failed to create device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDevice(devId, name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to update device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('updateDevice:', e);
|
||||||
|
alert('Failed to update device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||||
|
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||||
|
|
||||||
|
const devicesBtn = document.getElementById('devices-btn');
|
||||||
|
const devicesModal = document.getElementById('devices-modal');
|
||||||
|
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||||
|
const newName = document.getElementById('new-device-name');
|
||||||
|
const createBtn = document.getElementById('create-device-btn');
|
||||||
|
const editForm = document.getElementById('edit-device-form');
|
||||||
|
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||||
|
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||||
|
|
||||||
|
if (devicesBtn && devicesModal) {
|
||||||
|
devicesBtn.addEventListener('click', () => {
|
||||||
|
devicesModal.classList.add('active');
|
||||||
|
loadDevicesModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (devicesCloseBtn) {
|
||||||
|
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||||
|
const doCreate = async () => {
|
||||||
|
const name = (newName && newName.value.trim()) || '';
|
||||||
|
if (!name) {
|
||||||
|
alert('Device name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||||
|
const ok = await createDevice(name, address);
|
||||||
|
if (ok && newName) {
|
||||||
|
newName.value = '';
|
||||||
|
setAddressToBoxes(newAddressBoxes, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||||
|
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
const devId = idInput && idInput.value;
|
||||||
|
if (!devId) return;
|
||||||
|
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||||
|
const ok = await updateDevice(
|
||||||
|
devId,
|
||||||
|
nameInput ? nameInput.value.trim() : '',
|
||||||
|
address
|
||||||
|
);
|
||||||
|
if (ok) editDeviceModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editCloseBtn) {
|
||||||
|
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,72 @@
|
|||||||
let espnowSocket = null;
|
let espnowSocket = null;
|
||||||
let espnowSocketReady = false;
|
let espnowSocketReady = false;
|
||||||
let espnowPendingMessages = [];
|
let espnowPendingMessages = [];
|
||||||
|
let currentProfileIdCache = null;
|
||||||
|
|
||||||
|
const getCurrentProfileId = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||||
|
const data = await res.json();
|
||||||
|
const id = data && (data.id || (data.profile && data.profile.id));
|
||||||
|
currentProfileIdCache = id ? String(id) : null;
|
||||||
|
return currentProfileIdCache;
|
||||||
|
} catch (_) {
|
||||||
|
return currentProfileIdCache ? String(currentProfileIdCache) : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||||
|
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
|
||||||
|
const currentProfileId = await getCurrentProfileId();
|
||||||
|
if (!currentProfileId) return scoped;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(scoped).filter(([, preset]) => {
|
||||||
|
if (!preset || typeof preset !== 'object') return false;
|
||||||
|
if (!('profile_id' in preset)) return true; // Legacy records
|
||||||
|
return String(preset.profile_id) === String(currentProfileId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentProfilePaletteColors = async () => {
|
||||||
|
const profileData = await getCurrentProfileData();
|
||||||
|
const profile = profileData && profileData.profile;
|
||||||
|
const paletteId = profile && (profile.palette_id || profile.paletteId);
|
||||||
|
if (!paletteId) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const pal = await res.json();
|
||||||
|
return Array.isArray(pal.colors) ? pal.colors : [];
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => {
|
||||||
|
const baseColors = Array.isArray(colors) ? colors : [];
|
||||||
|
const refs = Array.isArray(paletteRefs) ? paletteRefs : [];
|
||||||
|
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||||||
|
return baseColors.map((color, idx) => {
|
||||||
|
const refRaw = refs[idx];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||||||
|
return pal[ref];
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getEspnowSocket = () => {
|
const getEspnowSocket = () => {
|
||||||
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
@@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const presetPatternInput = document.getElementById('preset-pattern-input');
|
const presetPatternInput = document.getElementById('preset-pattern-input');
|
||||||
const presetColorsContainer = document.getElementById('preset-colors-container');
|
const presetColorsContainer = document.getElementById('preset-colors-container');
|
||||||
const presetNewColorInput = document.getElementById('preset-new-color');
|
const presetNewColorInput = document.getElementById('preset-new-color');
|
||||||
const presetAddColorButton = document.getElementById('preset-add-color-btn');
|
|
||||||
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
const presetBrightnessInput = document.getElementById('preset-brightness-input');
|
||||||
const presetDelayInput = document.getElementById('preset-delay-input');
|
const presetDelayInput = document.getElementById('preset-delay-input');
|
||||||
const presetDefaultButton = document.getElementById('preset-default-btn');
|
const presetDefaultButton = document.getElementById('preset-default-btn');
|
||||||
@@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let cachedPresets = {};
|
let cachedPresets = {};
|
||||||
let cachedPatterns = {};
|
let cachedPatterns = {};
|
||||||
let currentPresetColors = []; // Track colors for the current preset
|
let currentPresetColors = []; // Track colors for the current preset
|
||||||
|
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||||||
|
|
||||||
// Function to get max colors for current pattern
|
// Function to get max colors for current pattern
|
||||||
const getMaxColors = () => {
|
const getMaxColors = () => {
|
||||||
@@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Hide/show the actions (color picker and buttons)
|
// Hide/show the actions (color picker and buttons)
|
||||||
const colorActions = presetColorsContainer.nextElementSibling;
|
const colorActions = presetColorsContainer.nextElementSibling;
|
||||||
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
|
if (colorActions && colorActions.querySelector('#preset-new-color')) {
|
||||||
colorActions.style.display = shouldShow ? '' : 'none';
|
colorActions.style.display = shouldShow ? '' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return parseInt(input.value, 10) || 0;
|
return parseInt(input.value, 10) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresetColors = (colors) => {
|
const renderPresetColors = (colors, paletteRefs) => {
|
||||||
if (!presetColorsContainer) return;
|
if (!presetColorsContainer) return;
|
||||||
|
|
||||||
presetColorsContainer.innerHTML = '';
|
presetColorsContainer.innerHTML = '';
|
||||||
currentPresetColors = colors || [];
|
currentPresetColors = Array.isArray(colors) ? colors.slice() : [];
|
||||||
|
if (Array.isArray(paletteRefs)) {
|
||||||
|
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||||
|
const refRaw = paletteRefs[i];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentPresetPaletteRefs = currentPresetColors.map((_, i) => {
|
||||||
|
const refRaw = currentPresetPaletteRefs[i];
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get max colors for current pattern
|
// Get max colors for current pattern
|
||||||
const maxColors = getMaxColors();
|
const maxColors = getMaxColors();
|
||||||
@@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (currentPresetColors.length === 0) {
|
if (currentPresetColors.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'muted-text';
|
empty.className = 'muted-text';
|
||||||
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
|
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
|
||||||
presetColorsContainer.appendChild(empty);
|
presetColorsContainer.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
|
||||||
swatchWrapper.draggable = true;
|
swatchWrapper.draggable = true;
|
||||||
swatchWrapper.dataset.colorIndex = index;
|
swatchWrapper.dataset.colorIndex = index;
|
||||||
|
const refAtIndex = currentPresetPaletteRefs[index];
|
||||||
|
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
|
||||||
swatchWrapper.classList.add('draggable-color-swatch');
|
swatchWrapper.classList.add('draggable-color-swatch');
|
||||||
|
|
||||||
const swatch = document.createElement('div');
|
const swatch = document.createElement('div');
|
||||||
@@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
transition: opacity 0.2s, transform 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
`;
|
`;
|
||||||
swatch.title = `${color} - Drag to reorder`;
|
swatch.title = `${color} - Drag to reorder`;
|
||||||
|
|
||||||
|
if (Number.isInteger(refAtIndex)) {
|
||||||
|
const linkedBadge = document.createElement('span');
|
||||||
|
linkedBadge.textContent = 'P';
|
||||||
|
linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`;
|
||||||
|
linkedBadge.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
left: -6px;
|
||||||
|
top: -6px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #3f51b5;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 11;
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||||
|
`;
|
||||||
|
swatchWrapper.appendChild(linkedBadge);
|
||||||
|
}
|
||||||
|
|
||||||
// Color picker overlay
|
// Color picker overlay
|
||||||
const colorPicker = document.createElement('input');
|
const colorPicker = document.createElement('input');
|
||||||
@@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
colorPicker.addEventListener('change', (e) => {
|
colorPicker.addEventListener('change', (e) => {
|
||||||
currentPresetColors[index] = e.target.value;
|
currentPresetColors[index] = e.target.value;
|
||||||
renderPresetColors(currentPresetColors);
|
// Manual picker edit breaks palette linkage for this slot.
|
||||||
|
currentPresetPaletteRefs[index] = null;
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
// Prevent color picker from interfering with drag
|
// Prevent color picker from interfering with drag
|
||||||
colorPicker.addEventListener('mousedown', (e) => {
|
colorPicker.addEventListener('mousedown', (e) => {
|
||||||
@@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
removeBtn.addEventListener('click', (e) => {
|
removeBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
currentPresetColors.splice(index, 1);
|
currentPresetColors.splice(index, 1);
|
||||||
renderPresetColors(currentPresetColors);
|
currentPresetPaletteRefs.splice(index, 1);
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
// Prevent remove button from interfering with drag
|
// Prevent remove button from interfering with drag
|
||||||
removeBtn.addEventListener('mousedown', (e) => {
|
removeBtn.addEventListener('mousedown', (e) => {
|
||||||
@@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const colorPicker = el.querySelector('input[type="color"]');
|
const colorPicker = el.querySelector('input[type="color"]');
|
||||||
return colorPicker ? colorPicker.value : null;
|
return colorPicker ? colorPicker.value : null;
|
||||||
}).filter(color => color !== null);
|
}).filter(color => color !== null);
|
||||||
|
const newRefOrder = colorElements.map((el) => {
|
||||||
|
const refRaw = el.dataset.paletteRef;
|
||||||
|
const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10);
|
||||||
|
return Number.isInteger(ref) ? ref : null;
|
||||||
|
});
|
||||||
|
|
||||||
// Update current colors array
|
// Update current colors array
|
||||||
currentPresetColors = newColorOrder;
|
currentPresetColors = newColorOrder;
|
||||||
|
currentPresetPaletteRefs = newRefOrder;
|
||||||
|
|
||||||
// Re-render to update indices
|
// Re-render to update indices
|
||||||
renderPresetColors(currentPresetColors);
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
|
|
||||||
presetColorsContainer.appendChild(swatchContainer);
|
presetColorsContainer.appendChild(swatchContainer);
|
||||||
@@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const patternName = preset.pattern || '';
|
const patternName = preset.pattern || '';
|
||||||
presetPatternInput.value = patternName;
|
presetPatternInput.value = patternName;
|
||||||
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
const colors = Array.isArray(preset.colors) ? preset.colors : [];
|
||||||
renderPresetColors(colors);
|
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
|
||||||
|
renderPresetColors(colors, paletteRefs);
|
||||||
presetBrightnessInput.value = preset.brightness || 0;
|
presetBrightnessInput.value = preset.brightness || 0;
|
||||||
presetDelayInput.value = preset.delay || 0;
|
presetDelayInput.value = preset.delay || 0;
|
||||||
|
|
||||||
@@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
currentEditId = null;
|
currentEditId = null;
|
||||||
currentEditTabId = null;
|
currentEditTabId = null;
|
||||||
currentPresetColors = [];
|
currentPresetColors = [];
|
||||||
|
currentPresetPaletteRefs = [];
|
||||||
setFormValues({
|
setFormValues({
|
||||||
name: '',
|
name: '',
|
||||||
pattern: '',
|
pattern: '',
|
||||||
@@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
name: presetNameInput ? presetNameInput.value.trim() : '',
|
name: presetNameInput ? presetNameInput.value.trim() : '',
|
||||||
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
|
||||||
colors: currentPresetColors || [],
|
colors: currentPresetColors || [],
|
||||||
|
palette_refs: currentPresetPaletteRefs || [],
|
||||||
// Use canonical field names expected by the device / API
|
// Use canonical field names expected by the device / API
|
||||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||||
@@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const editButton = document.createElement('button');
|
const editButton = document.createElement('button');
|
||||||
editButton.className = 'btn btn-secondary btn-small';
|
editButton.className = 'btn btn-secondary btn-small';
|
||||||
editButton.textContent = 'Edit';
|
editButton.textContent = 'Edit';
|
||||||
editButton.addEventListener('click', () => {
|
editButton.addEventListener('click', async () => {
|
||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
setFormValues(preset || {});
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
const presetForEditor = {
|
||||||
|
...(preset || {}),
|
||||||
|
colors: resolveColorsWithPaletteRefs(
|
||||||
|
(preset && preset.colors) || [],
|
||||||
|
(preset && preset.palette_refs) || [],
|
||||||
|
paletteColors,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setFormValues(presetForEditor);
|
||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const presets = await response.json();
|
const presets = await response.json();
|
||||||
renderPresets(presets);
|
const filtered = await filterPresetsForCurrentProfile(presets);
|
||||||
|
renderPresets(filtered);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load presets failed:', error);
|
console.error('Load presets failed:', error);
|
||||||
presetsList.innerHTML = '';
|
presetsList.innerHTML = '';
|
||||||
@@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const allPresets = await response.json();
|
const allPresetsRaw = await response.json();
|
||||||
|
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||||
|
|
||||||
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
// Load only the current tab's presets so we can avoid duplicates within this tab.
|
||||||
let currentTabPresets = [];
|
let currentTabPresets = [];
|
||||||
@@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Update color section visibility
|
// Update color section visibility
|
||||||
updateColorSectionVisibility();
|
updateColorSectionVisibility();
|
||||||
// Re-render colors to show updated max colors limit
|
// Re-render colors to show updated max colors limit
|
||||||
renderPresetColors(currentPresetColors);
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Add Color button handler
|
// Color picker auto-add handler
|
||||||
if (presetAddColorButton && presetNewColorInput) {
|
if (presetNewColorInput) {
|
||||||
presetAddColorButton.addEventListener('click', () => {
|
const tryAddSelectedColor = () => {
|
||||||
const color = presetNewColorInput.value;
|
const color = presetNewColorInput.value;
|
||||||
if (!color) return;
|
if (!color) return;
|
||||||
|
|
||||||
@@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPresetColors.push(color);
|
currentPresetColors.push(color);
|
||||||
renderPresetColors(currentPresetColors);
|
currentPresetPaletteRefs.push(null);
|
||||||
});
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
|
};
|
||||||
|
// Add when the picker closes (user confirms selection).
|
||||||
|
presetNewColorInput.addEventListener('change', tryAddSelectedColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from Palette button handler
|
|
||||||
if (presetAddFromPaletteButton) {
|
if (presetAddFromPaletteButton) {
|
||||||
presetAddFromPaletteButton.addEventListener('click', () => {
|
presetAddFromPaletteButton.addEventListener('click', async () => {
|
||||||
const openButton = document.getElementById('color-palette-btn');
|
try {
|
||||||
if (openButton) {
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
openButton.click();
|
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||||||
}
|
alert('No profile palette colors available.');
|
||||||
const modal = document.getElementById('color-palette-modal');
|
return;
|
||||||
const modalList = document.getElementById('palette-container');
|
}
|
||||||
if (modal) {
|
|
||||||
modal.classList.add('active');
|
|
||||||
}
|
|
||||||
if (!modalList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePick = (event) => {
|
const modal = document.createElement('div');
|
||||||
const row = event.target.closest('[data-color]');
|
modal.className = 'modal active';
|
||||||
if (!row) {
|
modal.innerHTML = `
|
||||||
return;
|
<div class="modal-content">
|
||||||
}
|
<h2>Pick Palette Color</h2>
|
||||||
const picked = row.dataset.color;
|
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||||
if (!picked) {
|
<div class="modal-actions">
|
||||||
return;
|
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
if (currentPresetColors.includes(picked)) {
|
`;
|
||||||
alert('This color is already in the list.');
|
document.body.appendChild(modal);
|
||||||
return;
|
|
||||||
}
|
const list = modal.querySelector('#pick-palette-list');
|
||||||
|
paletteColors.forEach((color, idx) => {
|
||||||
const maxColors = getMaxColors();
|
const row = document.createElement('div');
|
||||||
if (currentPresetColors.length >= maxColors) {
|
row.className = 'profiles-row';
|
||||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
row.style.display = 'flex';
|
||||||
if (modal) {
|
row.style.alignItems = 'center';
|
||||||
modal.classList.remove('active');
|
row.style.gap = '0.75rem';
|
||||||
|
row.dataset.paletteIndex = String(idx);
|
||||||
|
row.dataset.paletteColor = color;
|
||||||
|
row.innerHTML = `
|
||||||
|
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||||||
|
<span style="flex:1">${color}</span>
|
||||||
|
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => modal.remove();
|
||||||
|
modal.querySelector('#pick-palette-close-btn').addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
const row = e.target.closest('[data-palette-index]');
|
||||||
|
if (!row) return;
|
||||||
|
const color = row.dataset.paletteColor;
|
||||||
|
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||||
|
if (!color || !Number.isInteger(ref)) return;
|
||||||
|
|
||||||
|
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
|
||||||
|
alert('That palette color is already linked.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxColors = getMaxColors();
|
||||||
|
if (currentPresetColors.length >= maxColors) {
|
||||||
|
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
modalList.removeEventListener('click', handlePick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPresetColors.push(picked);
|
|
||||||
renderPresetColors(currentPresetColors);
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
}
|
|
||||||
modalList.removeEventListener('click', handlePick);
|
|
||||||
};
|
|
||||||
|
|
||||||
modalList.addEventListener('click', handlePick);
|
currentPresetColors.push(color);
|
||||||
|
currentPresetPaletteRefs.push(ref);
|
||||||
|
renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add from palette:', err);
|
||||||
|
alert('Failed to load palette colors.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const presetSendButton = document.getElementById('preset-send-btn');
|
const presetSendButton = document.getElementById('preset-send-btn');
|
||||||
@@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
currentEditTabId = tabId || null;
|
currentEditTabId = tabId || null;
|
||||||
await loadPatterns();
|
await loadPatterns();
|
||||||
setFormValues(preset);
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
setFormValues({
|
||||||
|
...(preset || {}),
|
||||||
|
colors: resolveColorsWithPaletteRefs(
|
||||||
|
(preset && preset.colors) || [],
|
||||||
|
(preset && preset.palette_refs) || [],
|
||||||
|
paletteColors,
|
||||||
|
),
|
||||||
|
});
|
||||||
openEditor();
|
openEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1175,11 +1338,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Build an ESPNow preset message for a single preset and optionally include a select
|
// Build an ESPNow preset message for a single preset and optionally include a select
|
||||||
// for the given device names, then send it via WebSocket.
|
// for the given device names, then send it via WebSocket.
|
||||||
// saveToDevice defaults to true.
|
// saveToDevice defaults to true.
|
||||||
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => {
|
||||||
try {
|
try {
|
||||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
? preset.colors
|
? preset.colors
|
||||||
: ['#FFFFFF'];
|
: ['#FFFFFF'];
|
||||||
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
v: '1',
|
v: '1',
|
||||||
@@ -1261,97 +1426,29 @@ try {
|
|||||||
|
|
||||||
// Store selected preset per tab
|
// Store selected preset per tab
|
||||||
const selectedPresets = {};
|
const selectedPresets = {};
|
||||||
|
// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode)
|
||||||
|
let presetUiMode = 'run';
|
||||||
|
|
||||||
|
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||||
|
|
||||||
|
const setPresetUiMode = (mode) => {
|
||||||
|
presetUiMode = mode === 'edit' ? 'edit' : 'run';
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUiModeToggleButtons = () => {
|
||||||
|
const mode = getPresetUiMode();
|
||||||
|
// Label is the mode you switch *to* (opposite of current)
|
||||||
|
const label = mode === 'edit' ? 'Run mode' : 'Edit mode';
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false');
|
||||||
|
btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit');
|
||||||
|
});
|
||||||
|
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
|
||||||
|
document.body.classList.toggle('preset-ui-run', mode === 'run');
|
||||||
|
};
|
||||||
// Track if we're currently dragging a preset
|
// Track if we're currently dragging a preset
|
||||||
let isDraggingPreset = false;
|
let isDraggingPreset = false;
|
||||||
// Context menu for tab presets
|
|
||||||
let presetContextMenu = null;
|
|
||||||
let presetContextTarget = null;
|
|
||||||
|
|
||||||
const ensurePresetContextMenu = () => {
|
|
||||||
if (presetContextMenu) {
|
|
||||||
return presetContextMenu;
|
|
||||||
}
|
|
||||||
const menu = document.createElement('div');
|
|
||||||
menu.id = 'preset-context-menu';
|
|
||||||
menu.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2000;
|
|
||||||
background: #2e2e2e;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
min-width: 160px;
|
|
||||||
display: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const addItem = (label, action) => {
|
|
||||||
const item = document.createElement('button');
|
|
||||||
item.type = 'button';
|
|
||||||
item.textContent = label;
|
|
||||||
item.dataset.action = action;
|
|
||||||
item.style.cssText = `
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
color: #eee;
|
|
||||||
border: none;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
`;
|
|
||||||
item.addEventListener('mouseover', () => {
|
|
||||||
item.style.backgroundColor = '#3a3a3a';
|
|
||||||
});
|
|
||||||
item.addEventListener('mouseout', () => {
|
|
||||||
item.style.backgroundColor = 'transparent';
|
|
||||||
});
|
|
||||||
menu.appendChild(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
addItem('Edit preset…', 'edit');
|
|
||||||
|
|
||||||
menu.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('button[data-action]');
|
|
||||||
if (!btn || !presetContextTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { presetId } = presetContextTarget;
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
hidePresetContextMenu();
|
|
||||||
if (action === 'edit') {
|
|
||||||
await editPresetFromTab(presetId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
presetContextMenu = menu;
|
|
||||||
|
|
||||||
// Hide on outside click
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!presetContextMenu) return;
|
|
||||||
if (e.target.closest('#preset-context-menu')) return;
|
|
||||||
hidePresetContextMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
|
|
||||||
const menu = ensurePresetContextMenu();
|
|
||||||
presetContextTarget = { tabId, presetId, preset };
|
|
||||||
menu.style.left = `${x}px`;
|
|
||||||
menu.style.top = `${y}px`;
|
|
||||||
menu.style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hidePresetContextMenu = () => {
|
|
||||||
if (presetContextMenu) {
|
|
||||||
presetContextMenu.style.display = 'none';
|
|
||||||
}
|
|
||||||
presetContextTarget = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to convert 2D grid to flat array (for backward compatibility)
|
// Function to convert 2D grid to flat array (for backward compatibility)
|
||||||
const gridToArray = (presetsGrid) => {
|
const gridToArray = (presetsGrid) => {
|
||||||
@@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => {
|
|||||||
return closest.element;
|
return closest.element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move dragged tile onto the drop target's slot.
|
||||||
|
* When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index
|
||||||
|
* too early; use the next element sibling so the item occupies the target slot.
|
||||||
|
*/
|
||||||
|
const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||||
|
const siblings = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||||
|
const fromIdx = siblings.indexOf(dragging);
|
||||||
|
const toIdx = siblings.indexOf(dropTarget);
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return;
|
||||||
|
|
||||||
|
if (fromIdx < toIdx) {
|
||||||
|
const next = dropTarget.nextElementSibling;
|
||||||
|
presetsList.insertBefore(dragging, next);
|
||||||
|
} else {
|
||||||
|
presetsList.insertBefore(dragging, dropTarget);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Function to render presets for a specific tab in 2D grid
|
// Function to render presets for a specific tab in 2D grid
|
||||||
const renderTabPresets = async (tabId) => {
|
const renderTabPresets = async (tabId) => {
|
||||||
const presetsList = document.getElementById('presets-list-tab');
|
const presetsList = document.getElementById('presets-list-tab');
|
||||||
@@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => {
|
|||||||
if (!presetsResponse.ok) {
|
if (!presetsResponse.ok) {
|
||||||
throw new Error('Failed to load presets');
|
throw new Error('Failed to load presets');
|
||||||
}
|
}
|
||||||
const allPresets = await presetsResponse.json();
|
const allPresetsRaw = await presetsResponse.json();
|
||||||
|
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
|
||||||
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
|
|
||||||
presetsList.innerHTML = '';
|
presetsList.innerHTML = '';
|
||||||
|
presetsList.dataset.reorderTabId = tabId;
|
||||||
// Add drag and drop handlers to the container
|
|
||||||
presetsList.addEventListener('dragover', (e) => {
|
// Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise)
|
||||||
e.preventDefault();
|
if (!presetsList.dataset.dragWired) {
|
||||||
const dragging = presetsList.querySelector('.dragging');
|
presetsList.dataset.dragWired = '1';
|
||||||
if (!dragging) return;
|
// dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor)
|
||||||
|
presetsList.addEventListener('dragenter', (e) => {
|
||||||
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
if (dropTarget && dropTarget !== dragging) {
|
e.preventDefault();
|
||||||
// Insert before drop target so the dragged item takes that cell's position
|
});
|
||||||
presetsList.insertBefore(dragging, dropTarget);
|
presetsList.addEventListener('dragover', (e) => {
|
||||||
}
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
});
|
e.preventDefault();
|
||||||
|
try {
|
||||||
presetsList.addEventListener('drop', async (e) => {
|
e.dataTransfer.dropEffect = 'move';
|
||||||
e.preventDefault();
|
} catch (_) {}
|
||||||
const dragging = presetsList.querySelector('.dragging');
|
const dragging = presetsList.querySelector('.dragging');
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
// Get new grid layout from DOM
|
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
|
||||||
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
// Keep dragover side-effect free; commit placement only on drop.
|
||||||
const presetIds = presetElements.map(el => el.dataset.presetId);
|
if (!dropTarget || dropTarget === dragging) {
|
||||||
|
delete presetsList.dataset.dropTargetId;
|
||||||
// Convert to 2D grid (3 columns)
|
return;
|
||||||
const newGrid = arrayToGrid(presetIds, 3);
|
}
|
||||||
|
presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || '';
|
||||||
// Save new grid
|
});
|
||||||
try {
|
|
||||||
await savePresetGrid(tabId, newGrid);
|
presetsList.addEventListener('drop', async (e) => {
|
||||||
// Re-render to ensure consistency
|
if (getPresetUiMode() !== 'edit') return;
|
||||||
await renderTabPresets(tabId);
|
e.preventDefault();
|
||||||
} catch (error) {
|
const dragging = presetsList.querySelector('.dragging');
|
||||||
console.error('Failed to save preset grid:', error);
|
if (!dragging) return;
|
||||||
alert('Failed to save preset order. Please try again.');
|
const targetId = presetsList.dataset.dropTargetId;
|
||||||
// Re-render to restore original order
|
if (targetId) {
|
||||||
await renderTabPresets(tabId);
|
const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`);
|
||||||
}
|
if (dropTarget) {
|
||||||
});
|
insertDraggingOntoTarget(presetsList, dragging, dropTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete presetsList.dataset.dropTargetId;
|
||||||
|
|
||||||
|
const saveId = presetsList.dataset.reorderTabId;
|
||||||
|
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
|
||||||
|
const presetIds = presetElements.map((el) => el.dataset.presetId);
|
||||||
|
|
||||||
|
const newGrid = arrayToGrid(presetIds, 3);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!saveId) {
|
||||||
|
console.warn('No tab id for preset reorder save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await savePresetGrid(saveId, newGrid);
|
||||||
|
await renderTabPresets(saveId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save preset grid:', error);
|
||||||
|
alert('Failed to save preset order. Please try again.');
|
||||||
|
const fallbackId = presetsList.dataset.reorderTabId;
|
||||||
|
if (fallbackId) await renderTabPresets(fallbackId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Get the currently selected preset for this tab
|
// Get the currently selected preset for this tab
|
||||||
const selectedPresetId = selectedPresets[tabId];
|
const selectedPresetId = selectedPresets[tabId];
|
||||||
@@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => {
|
|||||||
const preset = allPresets[presetId];
|
const preset = allPresets[presetId];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
const isSelected = presetId === selectedPresetId;
|
const isSelected = presetId === selectedPresetId;
|
||||||
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
|
const displayPreset = {
|
||||||
|
...preset,
|
||||||
|
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||||
|
};
|
||||||
|
const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected);
|
||||||
presetsList.appendChild(wrapper);
|
presetsList.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||||
|
const uiMode = getPresetUiMode();
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
const canDrag = uiMode === 'edit';
|
||||||
|
row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`;
|
||||||
|
row.draggable = canDrag;
|
||||||
|
row.dataset.presetId = presetId;
|
||||||
|
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'pattern-button draggable-preset';
|
button.type = 'button';
|
||||||
button.draggable = true;
|
button.className = 'pattern-button preset-tile-main';
|
||||||
button.dataset.presetId = presetId;
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
|
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||||
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
||||||
const barColors = isRainbow
|
const barColors = isRainbow
|
||||||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||||||
@@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
presetNameLabel.className = 'pattern-button-label';
|
presetNameLabel.className = 'pattern-button-label';
|
||||||
button.appendChild(presetNameLabel);
|
button.appendChild(presetNameLabel);
|
||||||
|
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', () => {
|
||||||
if (isDraggingPreset) return;
|
if (isDraggingPreset) return;
|
||||||
const presetsList = document.getElementById('presets-list-tab');
|
const presetsListEl = document.getElementById('presets-list-tab');
|
||||||
if (presetsList) {
|
if (presetsListEl) {
|
||||||
presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
|
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||||
}
|
}
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
selectedPresets[tabId] = presetId;
|
selectedPresets[tabId] = presetId;
|
||||||
const section = button.closest('.presets-section');
|
const section = row.closest('.presets-section');
|
||||||
sendSelectForCurrentTabDevices(presetId, section);
|
sendSelectForCurrentTabDevices(presetId, section);
|
||||||
});
|
});
|
||||||
|
|
||||||
button.addEventListener('contextmenu', async (e) => {
|
if (canDrag) {
|
||||||
e.preventDefault();
|
row.addEventListener('dragstart', (e) => {
|
||||||
if (isDraggingPreset) return;
|
isDraggingPreset = true;
|
||||||
await editPresetFromTab(presetId, tabId, preset);
|
row.classList.add('dragging');
|
||||||
});
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', presetId);
|
||||||
|
});
|
||||||
|
|
||||||
button.addEventListener('dragstart', (e) => {
|
row.addEventListener('dragend', () => {
|
||||||
isDraggingPreset = true;
|
row.classList.remove('dragging');
|
||||||
button.classList.add('dragging');
|
const presetsListEl = document.getElementById('presets-list-tab');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
if (presetsListEl) {
|
||||||
e.dataTransfer.setData('text/plain', presetId);
|
delete presetsListEl.dataset.dropTargetId;
|
||||||
});
|
}
|
||||||
|
document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over'));
|
||||||
|
setTimeout(() => {
|
||||||
|
isDraggingPreset = false;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
button.addEventListener('dragend', (e) => {
|
row.appendChild(button);
|
||||||
button.classList.remove('dragging');
|
|
||||||
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
|
|
||||||
setTimeout(() => { isDraggingPreset = false; }, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
return button;
|
if (uiMode === 'edit') {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'preset-tile-actions';
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.title = 'Edit preset';
|
||||||
|
editBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDraggingPreset) return;
|
||||||
|
editPresetFromTab(presetId, tabId, preset);
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'btn btn-danger btn-small';
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.title = 'Remove from this tab';
|
||||||
|
removeBtn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDraggingPreset) return;
|
||||||
|
if (!window.confirm('Remove this preset from this tab?')) return;
|
||||||
|
await removePresetFromTab(tabId, presetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
row.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
|
||||||
@@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
updateUiModeToggleButtons();
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||||
|
setPresetUiMode(next);
|
||||||
|
updateUiModeToggleButtons();
|
||||||
|
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||||
|
if (mainMenu) mainMenu.classList.remove('open');
|
||||||
|
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
if (leftPanel) {
|
||||||
|
renderTabPresets(leftPanel.dataset.tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||||
const profilesList = document.getElementById("profiles-list");
|
const profilesList = document.getElementById("profiles-list");
|
||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
@@ -19,6 +20,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
profilesModal.classList.remove("active");
|
profilesModal.classList.remove("active");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshTabsForActiveProfile = async () => {
|
||||||
|
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||||
|
document.cookie = "current_tab=; path=/; max-age=0";
|
||||||
|
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||||
|
await window.tabsManager.loadTabs();
|
||||||
|
}
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
|
||||||
|
await window.tabsManager.loadTabsModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderProfiles = (profiles, currentProfileId) => {
|
const renderProfiles = (profiles, currentProfileId) => {
|
||||||
profilesList.innerHTML = "";
|
profilesList.innerHTML = "";
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -66,7 +79,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
throw new Error("Failed to apply profile");
|
throw new Error("Failed to apply profile");
|
||||||
}
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
document.body.dispatchEvent(new Event("tabs-updated"));
|
await refreshTabsForActiveProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Apply profile failed:", error);
|
console.error("Apply profile failed:", error);
|
||||||
alert("Failed to apply profile.");
|
alert("Failed to apply profile.");
|
||||||
@@ -115,22 +128,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.cookie = "current_tab=; path=/; max-age=0";
|
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
if (typeof window.loadTabs === "function") {
|
await refreshTabsForActiveProfile();
|
||||||
await window.loadTabs();
|
|
||||||
}
|
|
||||||
if (typeof window.loadTabsModal === "function") {
|
|
||||||
await window.loadTabsModal();
|
|
||||||
}
|
|
||||||
const tabContent = document.getElementById("tab-content");
|
|
||||||
if (tabContent) {
|
|
||||||
tabContent.innerHTML = `
|
|
||||||
<div class="tab-content-placeholder">
|
|
||||||
Select a tab to get started
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Clone profile failed:", error);
|
console.error("Clone profile failed:", error);
|
||||||
alert("Failed to clone profile.");
|
alert("Failed to clone profile.");
|
||||||
@@ -210,7 +209,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const response = await fetch("/profiles", {
|
const response = await fetch("/profiles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to create profile");
|
throw new Error("Failed to create profile");
|
||||||
@@ -236,23 +238,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newProfileInput.value = "";
|
newProfileInput.value = "";
|
||||||
// Clear current tab and refresh the UI so the new profile starts empty.
|
if (newProfileSeedDjInput) {
|
||||||
document.cookie = "current_tab=; path=/; max-age=0";
|
newProfileSeedDjInput.checked = false;
|
||||||
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
if (typeof window.loadTabs === "function") {
|
await refreshTabsForActiveProfile();
|
||||||
await window.loadTabs();
|
|
||||||
}
|
|
||||||
if (typeof window.loadTabsModal === "function") {
|
|
||||||
await window.loadTabsModal();
|
|
||||||
}
|
|
||||||
const tabContent = document.getElementById("tab-content");
|
|
||||||
if (tabContent) {
|
|
||||||
tabContent.innerHTML = `
|
|
||||||
<div class="tab-content-placeholder">
|
|
||||||
Select a tab to get started
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create profile failed:", error);
|
console.error("Create profile failed:", error);
|
||||||
alert("Failed to create profile.");
|
alert("Failed to create profile.");
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ header h1 {
|
|||||||
background-color: #333;
|
background-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header/menu actions that should only appear in Edit mode */
|
||||||
|
body.preset-ui-run .edit-mode-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -596,6 +601,52 @@ header h1 {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preset tile: main button + optional edit/remove (Edit mode) */
|
||||||
|
.preset-tile-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-row--run .preset-tile-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit {
|
||||||
|
background-color: #4a3f8f;
|
||||||
|
border: 1px solid #7b6fd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit:hover {
|
||||||
|
background-color: #5a4f9f;
|
||||||
|
}
|
||||||
|
|
||||||
/* Preset select buttons inside the tab grid */
|
/* Preset select buttons inside the tab grid */
|
||||||
#presets-list-tab .pattern-button {
|
#presets-list-tab .pattern-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// Tab management JavaScript
|
// Tab management JavaScript
|
||||||
let currentTabId = null;
|
let currentTabId = null;
|
||||||
|
|
||||||
|
const isEditModeActive = () => {
|
||||||
|
const toggle = document.querySelector('.ui-mode-toggle');
|
||||||
|
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||||
|
};
|
||||||
|
|
||||||
// Get current tab from cookie
|
// Get current tab from cookie
|
||||||
function getCurrentTabFromCookie() {
|
function getCurrentTabFromCookie() {
|
||||||
const cookies = document.cookie.split(';');
|
const cookies = document.cookie.split(';');
|
||||||
@@ -38,10 +43,12 @@ async function loadTabs() {
|
|||||||
|
|
||||||
// Load current tab content if available
|
// Load current tab content if available
|
||||||
if (currentTabId) {
|
if (currentTabId) {
|
||||||
loadTabContent(currentTabId);
|
await loadTabContent(currentTabId);
|
||||||
} else if (data.tab_order && data.tab_order.length > 0) {
|
} else if (data.tab_order && data.tab_order.length > 0) {
|
||||||
// Set first tab as current if none is set
|
// Set first tab as current if none is set
|
||||||
await setCurrentTab(data.tab_order[0]);
|
const firstTabId = data.tab_order[0];
|
||||||
|
await setCurrentTab(firstTabId);
|
||||||
|
await loadTabContent(firstTabId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tabs:', error);
|
console.error('Failed to load tabs:', error);
|
||||||
@@ -62,6 +69,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
let html = '<div class="tabs-list">';
|
let html = '<div class="tabs-list">';
|
||||||
for (const tabId of tabOrder) {
|
for (const tabId of tabOrder) {
|
||||||
const tab = tabs[tabId];
|
const tab = tabs[tabId];
|
||||||
@@ -71,7 +79,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
|||||||
html += `
|
html += `
|
||||||
<button class="tab-button ${activeClass}"
|
<button class="tab-button ${activeClass}"
|
||||||
data-tab-id="${tabId}"
|
data-tab-id="${tabId}"
|
||||||
title="Click to select, right-click to edit"
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||||
onclick="selectTab('${tabId}')">
|
onclick="selectTab('${tabId}')">
|
||||||
${tabName}
|
${tabName}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,6 +114,7 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
entries.forEach(([tabId, tab]) => {
|
entries.forEach(([tabId, tab]) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "profiles-row";
|
row.className = "profiles-row";
|
||||||
@@ -224,10 +233,12 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
|||||||
|
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
row.appendChild(editButton);
|
|
||||||
row.appendChild(sendPresetsButton);
|
row.appendChild(sendPresetsButton);
|
||||||
row.appendChild(cloneButton);
|
if (editMode) {
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
}
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -714,6 +725,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Right-click on a tab button in the main header bar to edit that tab
|
// Right-click on a tab button in the main header bar to edit that tab
|
||||||
document.addEventListener('contextmenu', async (event) => {
|
document.addEventListener('contextmenu', async (event) => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = event.target.closest('.tab-button');
|
const btn = event.target.closest('.tab-button');
|
||||||
if (!btn || !btn.dataset.tabId) {
|
if (!btn || !btn.dataset.tabId) {
|
||||||
return;
|
return;
|
||||||
@@ -796,11 +810,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
await sendProfilePresets();
|
await sendProfilePresets();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await loadTabs();
|
||||||
|
if (tabsModal && tabsModal.classList.contains('active')) {
|
||||||
|
await loadTabsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.tabsManager = {
|
window.tabsManager = {
|
||||||
loadTabs,
|
loadTabs,
|
||||||
|
loadTabsModal,
|
||||||
selectTab,
|
selectTab,
|
||||||
createTab,
|
createTab,
|
||||||
updateTab,
|
updateTab,
|
||||||
|
|||||||
@@ -15,24 +15,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
|
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||||
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Color Palette</button>
|
||||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
|
||||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary edit-mode-only" id="profiles-btn">Profiles</button>
|
||||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
|
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-menu-mobile">
|
<div class="header-menu-mobile">
|
||||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
<button type="button" data-target="tabs-btn">Tabs</button>
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
<button type="button" data-target="color-palette-btn">Color Palette</button>
|
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||||
<button type="button" data-target="presets-btn">Presets</button>
|
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Color Palette</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||||
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
<button type="button" data-target="patterns-btn">Patterns</button>
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
<button type="button" class="edit-mode-only" data-target="profiles-btn">Profiles</button>
|
||||||
<button type="button" data-target="settings-btn">Settings</button>
|
<button type="button" data-target="settings-btn">Settings</button>
|
||||||
<button type="button" data-target="help-btn">Help</button>
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +94,12 @@
|
|||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
|
<input type="checkbox" id="new-profile-seed-dj">
|
||||||
|
DJ tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div id="profiles-list" class="profiles-list"></div>
|
<div id="profiles-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||||
@@ -126,9 +134,8 @@
|
|||||||
<label>Colors</label>
|
<label>Colors</label>
|
||||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="preset-new-color" value="#ffffff">
|
<input type="color" id="preset-new-color" value="#ffffff" title="Choose color (auto-adds)">
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
|
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<div class="preset-editor-field">
|
<div class="preset-editor-field">
|
||||||
@@ -204,7 +211,6 @@
|
|||||||
<div id="palette-container" class="profiles-list"></div>
|
<div id="palette-container" class="profiles-list"></div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="palette-new-color" value="#ffffff">
|
<input type="color" id="palette-new-color" value="#ffffff">
|
||||||
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||||
@@ -228,9 +234,10 @@
|
|||||||
<h3>Presets in a tab</h3>
|
<h3>Presets in a tab</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
|
||||||
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
|
<li><strong>Run vs Edit mode</strong>: use the mode button in the menu (it shows the mode you will <em>switch to</em>). In <strong>Edit mode</strong>, each preset tile shows <strong>Edit</strong> and <strong>Remove</strong> on the right.</li>
|
||||||
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
|
<li><strong>Edit preset</strong>: switch to <strong>Edit mode</strong> (menu button) and use <strong>Edit</strong> on each tile.</li>
|
||||||
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
|
<li><strong>Remove from tab</strong>: in <strong>Edit mode</strong>, use <strong>Remove</strong> on the tile (the preset itself is not deleted, only its link from this tab).</li>
|
||||||
|
<li><strong>Reorder presets</strong>: in <strong>Edit mode</strong> only, drag tiles to change order; the layout saves automatically.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Presets, profiles & colors</h3>
|
<h3>Presets, profiles & colors</h3>
|
||||||
@@ -287,7 +294,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-password">AP Password</label>
|
<label for="ap-password">AP Password</label>
|
||||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-password">AP Password</label>
|
<label for="ap-password">AP Password</label>
|
||||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from test_group import test_group
|
|||||||
from test_sequence import test_sequence
|
from test_sequence import test_sequence
|
||||||
from test_tab import test_tab
|
from test_tab import test_tab
|
||||||
from test_palette import test_palette
|
from test_palette import test_palette
|
||||||
|
from test_device import test_device
|
||||||
|
|
||||||
def run_all_tests():
|
def run_all_tests():
|
||||||
"""Run all model tests."""
|
"""Run all model tests."""
|
||||||
@@ -27,6 +28,7 @@ def run_all_tests():
|
|||||||
("Sequence", test_sequence),
|
("Sequence", test_sequence),
|
||||||
("Tab", test_tab),
|
("Tab", test_tab),
|
||||||
("Palette", test_palette),
|
("Palette", test_palette),
|
||||||
|
("Device", test_device),
|
||||||
]
|
]
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
|
|||||||
64
tests/models/test_device.py
Normal file
64
tests/models/test_device.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from models.device import Device
|
||||||
|
import os
|
||||||
|
|
||||||
|
def test_device():
|
||||||
|
"""Test Device model CRUD operations."""
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
device_file = os.path.join(db_dir, "device.json")
|
||||||
|
if os.path.exists(device_file):
|
||||||
|
os.remove(device_file)
|
||||||
|
|
||||||
|
devices = Device()
|
||||||
|
|
||||||
|
print("Testing create device")
|
||||||
|
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
|
||||||
|
print(f"Created device with ID: {device_id}")
|
||||||
|
assert device_id is not None
|
||||||
|
assert device_id in devices
|
||||||
|
|
||||||
|
print("\nTesting read device")
|
||||||
|
device = devices.read(device_id)
|
||||||
|
print(f"Read: {device}")
|
||||||
|
assert device is not None
|
||||||
|
assert device["name"] == "Test Device"
|
||||||
|
assert device["address"] == "aabbccddeeff"
|
||||||
|
assert device["default_pattern"] == "on"
|
||||||
|
assert device["tabs"] == ["1", "2"]
|
||||||
|
|
||||||
|
print("\nTesting address normalization")
|
||||||
|
devices.update(device_id, {"address": "11:22:33:44:55:66"})
|
||||||
|
updated = devices.read(device_id)
|
||||||
|
assert updated["address"] == "112233445566"
|
||||||
|
|
||||||
|
print("\nTesting update device")
|
||||||
|
update_data = {
|
||||||
|
"name": "Updated Device",
|
||||||
|
"default_pattern": "rainbow",
|
||||||
|
"tabs": ["1", "2", "3"],
|
||||||
|
}
|
||||||
|
result = devices.update(device_id, update_data)
|
||||||
|
assert result is True
|
||||||
|
updated = devices.read(device_id)
|
||||||
|
assert updated["name"] == "Updated Device"
|
||||||
|
assert updated["default_pattern"] == "rainbow"
|
||||||
|
assert len(updated["tabs"]) == 3
|
||||||
|
|
||||||
|
print("\nTesting list devices")
|
||||||
|
device_list = devices.list()
|
||||||
|
print(f"Device list: {device_list}")
|
||||||
|
assert device_id in device_list
|
||||||
|
|
||||||
|
print("\nTesting delete device")
|
||||||
|
deleted = devices.delete(device_id)
|
||||||
|
assert deleted is True
|
||||||
|
assert device_id not in devices
|
||||||
|
|
||||||
|
print("\nTesting read after delete")
|
||||||
|
device = devices.read(device_id)
|
||||||
|
assert device is None
|
||||||
|
|
||||||
|
print("\nAll device tests passed!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_device()
|
||||||
@@ -6,11 +6,13 @@ def test_model():
|
|||||||
# Create a test model class
|
# Create a test model class
|
||||||
class TestModel(Model):
|
class TestModel(Model):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clean up any existing test file
|
# Clean up any existing test file (model uses db/<classname>.json)
|
||||||
if os.path.exists("TestModel.json"):
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
os.remove("TestModel.json")
|
testmodel_file = os.path.join(db_dir, "testmodel.json")
|
||||||
|
if os.path.exists(testmodel_file):
|
||||||
|
os.remove(testmodel_file)
|
||||||
|
|
||||||
model = TestModel()
|
model = TestModel()
|
||||||
|
|
||||||
print("Testing get_next_id with empty model")
|
print("Testing get_next_id with empty model")
|
||||||
@@ -43,9 +45,9 @@ def test_model():
|
|||||||
assert hasattr(model2, 'set_defaults')
|
assert hasattr(model2, 'set_defaults')
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
if os.path.exists("TestModel.json"):
|
if os.path.exists(testmodel_file):
|
||||||
os.remove("TestModel.json")
|
os.remove(testmodel_file)
|
||||||
|
|
||||||
print("\nAll model base class tests passed!")
|
print("\nAll model base class tests passed!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ from models.pallet import Palette
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
def test_palette():
|
def test_palette():
|
||||||
"""Test Palette model CRUD operations."""
|
"""Test Palette model CRUD operations.
|
||||||
# Clean up any existing test file
|
Palette stores a list of colors per ID; read() returns that list (or unwraps from dict).
|
||||||
if os.path.exists("Palette.json"):
|
"""
|
||||||
os.remove("Palette.json")
|
# Clean up any existing test file (model uses db/palette.json from project root)
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
palette_file = os.path.join(db_dir, "palette.json")
|
||||||
|
if os.path.exists(palette_file):
|
||||||
|
os.remove(palette_file)
|
||||||
|
|
||||||
palettes = Palette()
|
palettes = Palette()
|
||||||
|
|
||||||
@@ -19,10 +23,12 @@ def test_palette():
|
|||||||
print("\nTesting read palette")
|
print("\nTesting read palette")
|
||||||
palette = palettes.read(palette_id)
|
palette = palettes.read(palette_id)
|
||||||
print(f"Read: {palette}")
|
print(f"Read: {palette}")
|
||||||
|
# read() returns list of colors (name is not stored)
|
||||||
assert palette is not None
|
assert palette is not None
|
||||||
assert palette["name"] == "test_palette"
|
assert isinstance(palette, list) or (isinstance(palette, dict) and "colors" in palette)
|
||||||
assert len(palette["colors"]) == 4
|
colors_read = palette if isinstance(palette, list) else palette.get("colors", [])
|
||||||
assert "#FF0000" in palette["colors"]
|
assert len(colors_read) == 4
|
||||||
|
assert "#FF0000" in colors_read
|
||||||
|
|
||||||
print("\nTesting update palette")
|
print("\nTesting update palette")
|
||||||
update_data = {
|
update_data = {
|
||||||
@@ -32,9 +38,9 @@ def test_palette():
|
|||||||
result = palettes.update(palette_id, update_data)
|
result = palettes.update(palette_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = palettes.read(palette_id)
|
updated = palettes.read(palette_id)
|
||||||
assert updated["name"] == "updated_palette"
|
updated_colors = updated if isinstance(updated, list) else (updated.get("colors") or [])
|
||||||
assert len(updated["colors"]) == 3
|
assert len(updated_colors) == 3
|
||||||
assert "#FF00FF" in updated["colors"]
|
assert "#FF00FF" in updated_colors
|
||||||
|
|
||||||
print("\nTesting list palettes")
|
print("\nTesting list palettes")
|
||||||
palette_list = palettes.list()
|
palette_list = palettes.list()
|
||||||
@@ -48,7 +54,8 @@ def test_palette():
|
|||||||
|
|
||||||
print("\nTesting read after delete")
|
print("\nTesting read after delete")
|
||||||
palette = palettes.read(palette_id)
|
palette = palettes.read(palette_id)
|
||||||
assert palette is None
|
# read() returns [] when id is missing (value or [])
|
||||||
|
assert palette == [] or palette is None
|
||||||
|
|
||||||
print("\nAll palette tests passed!")
|
print("\nAll palette tests passed!")
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ from models.profile import Profile
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
def test_profile():
|
def test_profile():
|
||||||
"""Test Profile model CRUD operations."""
|
"""Test Profile model CRUD operations.
|
||||||
# Clean up any existing test file
|
Profile create() sets name, type, tabs (list of tab IDs), scenes, palette_id.
|
||||||
if os.path.exists("Profile.json"):
|
"""
|
||||||
os.remove("Profile.json")
|
# Clean up any existing test file (model uses db/profile.json from project root)
|
||||||
|
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||||
|
profile_file = os.path.join(db_dir, "profile.json")
|
||||||
|
if os.path.exists(profile_file):
|
||||||
|
os.remove(profile_file)
|
||||||
|
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
|
||||||
@@ -21,15 +25,13 @@ def test_profile():
|
|||||||
assert profile is not None
|
assert profile is not None
|
||||||
assert profile["name"] == "test_profile"
|
assert profile["name"] == "test_profile"
|
||||||
assert "tabs" in profile
|
assert "tabs" in profile
|
||||||
assert "palette" in profile
|
assert "palette_id" in profile
|
||||||
assert "tab_order" in profile
|
assert "type" in profile
|
||||||
|
|
||||||
print("\nTesting update profile")
|
print("\nTesting update profile")
|
||||||
update_data = {
|
update_data = {
|
||||||
"name": "updated_profile",
|
"name": "updated_profile",
|
||||||
"tabs": {"tab1": {"names": ["1"], "presets": []}},
|
"tabs": ["tab1"],
|
||||||
"palette": ["#FF0000", "#00FF00"],
|
|
||||||
"tab_order": ["tab1"]
|
|
||||||
}
|
}
|
||||||
result = profiles.update(profile_id, update_data)
|
result = profiles.update(profile_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Browser automation tests using Selenium.
|
Browser automation tests using Selenium.
|
||||||
Tests run against the device at 192.168.4.1 in an actual browser.
|
Tests run against the device at 192.168.4.1 in an actual browser.
|
||||||
|
|
||||||
|
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
|
||||||
|
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -13,8 +16,8 @@ from selenium import webdriver
|
|||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
from selenium.webdriver.chrome.options import Options
|
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||||
from selenium.webdriver.chrome.service import Service
|
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||||
|
|
||||||
@@ -33,24 +36,41 @@ class BrowserTest:
|
|||||||
self.created_presets: List[str] = []
|
self.created_presets: List[str] = []
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""Set up the browser driver."""
|
"""Set up the browser driver. Tries Chrome first, then Firefox."""
|
||||||
|
err_chrome, err_firefox = None, None
|
||||||
|
# Try Chrome first
|
||||||
try:
|
try:
|
||||||
chrome_options = Options()
|
opts = ChromeOptions()
|
||||||
if self.headless:
|
if self.headless:
|
||||||
chrome_options.add_argument('--headless')
|
opts.add_argument('--headless')
|
||||||
chrome_options.add_argument('--no-sandbox')
|
opts.add_argument('--no-sandbox')
|
||||||
chrome_options.add_argument('--disable-dev-shm-usage')
|
opts.add_argument('--disable-dev-shm-usage')
|
||||||
chrome_options.add_argument('--disable-gpu')
|
opts.add_argument('--disable-gpu')
|
||||||
chrome_options.add_argument('--window-size=1920,1080')
|
opts.add_argument('--window-size=1920,1080')
|
||||||
|
self.driver = webdriver.Chrome(options=opts)
|
||||||
self.driver = webdriver.Chrome(options=chrome_options)
|
|
||||||
self.driver.implicitly_wait(5)
|
self.driver.implicitly_wait(5)
|
||||||
print("✓ Browser started")
|
print("✓ Browser started (Chrome)")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Failed to start browser: {e}")
|
err_chrome = e
|
||||||
print(" Make sure Chrome and ChromeDriver are installed")
|
# Fallback to Firefox
|
||||||
return False
|
try:
|
||||||
|
opts = FirefoxOptions()
|
||||||
|
if self.headless:
|
||||||
|
opts.add_argument('--headless')
|
||||||
|
self.driver = webdriver.Firefox(options=opts)
|
||||||
|
self.driver.implicitly_wait(5)
|
||||||
|
print("✓ Browser started (Firefox)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
err_firefox = e
|
||||||
|
print("✗ Failed to start browser.")
|
||||||
|
if err_chrome:
|
||||||
|
print(f" Chrome: {err_chrome}")
|
||||||
|
if err_firefox:
|
||||||
|
print(f" Firefox: {err_firefox}")
|
||||||
|
print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver")
|
||||||
|
return False
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Close the browser."""
|
"""Close the browser."""
|
||||||
@@ -209,46 +229,6 @@ class BrowserTest:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠ Cleanup error: {e}")
|
print(f" ⚠ Cleanup error: {e}")
|
||||||
|
|
||||||
def cleanup_test_data(self):
|
|
||||||
"""Clean up test data created during tests."""
|
|
||||||
try:
|
|
||||||
# Use requests to make API calls for cleanup
|
|
||||||
session = requests.Session()
|
|
||||||
|
|
||||||
# Delete created presets
|
|
||||||
for preset_id in self.created_presets:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/presets/{preset_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up preset: {preset_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
|
|
||||||
|
|
||||||
# Delete created tabs
|
|
||||||
for tab_id in self.created_tabs:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/tabs/{tab_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up tab: {tab_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup tab {tab_id}: {e}")
|
|
||||||
|
|
||||||
# Delete created profiles
|
|
||||||
for profile_id in self.created_profiles:
|
|
||||||
try:
|
|
||||||
response = session.delete(f"{self.base_url}/profiles/{profile_id}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✓ Cleaned up profile: {profile_id}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Failed to cleanup profile {profile_id}: {e}")
|
|
||||||
|
|
||||||
# Clear the lists
|
|
||||||
self.created_tabs.clear()
|
|
||||||
self.created_profiles.clear()
|
|
||||||
self.created_presets.clear()
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ⚠ Cleanup error: {e}")
|
|
||||||
|
|
||||||
def fill_input(self, by, value, text, timeout=10):
|
def fill_input(self, by, value, text, timeout=10):
|
||||||
"""Fill an input field."""
|
"""Fill an input field."""
|
||||||
try:
|
try:
|
||||||
@@ -553,7 +533,7 @@ def test_mobile_tab_presets_two_columns():
|
|||||||
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
container = bt.wait_for_element(By.ID, 'presets-list-tab', timeout=10)
|
||||||
assert container is not None, "presets-list-tab not found"
|
assert container is not None, "presets-list-tab not found"
|
||||||
|
|
||||||
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .preset-tile-row')
|
||||||
# Need at least 2 presets to make this meaningful
|
# Need at least 2 presets to make this meaningful
|
||||||
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
|
assert len(tiles) >= 2, "Fewer than 2 presets found for tab"
|
||||||
|
|
||||||
@@ -902,14 +882,20 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# Test 5: Find presets in tab and test drag and drop
|
# Test 5: Find presets in tab and test drag and drop (Edit mode only)
|
||||||
total += 1
|
total += 1
|
||||||
try:
|
try:
|
||||||
# Wait for presets to load in the tab
|
# Wait for presets to load in the tab
|
||||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-tab', timeout=5)
|
||||||
if presets_list_tab:
|
if presets_list_tab:
|
||||||
time.sleep(1) # Wait for presets to render
|
time.sleep(1) # Wait for presets to render
|
||||||
|
|
||||||
|
# Reordering is only available in Edit mode (tiles get .draggable-preset)
|
||||||
|
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
|
||||||
|
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
|
||||||
|
mode_toggle.click()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Find draggable preset elements - wait a bit more for rendering
|
# Find draggable preset elements - wait a bit more for rendering
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-tab .draggable-preset')
|
||||||
@@ -1005,11 +991,19 @@ def main():
|
|||||||
print("LED Controller Browser Tests")
|
print("LED Controller Browser Tests")
|
||||||
print(f"Testing against: {BASE_URL}")
|
print(f"Testing against: {BASE_URL}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
|
# On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing
|
||||||
|
browser = BrowserTest(headless=True)
|
||||||
|
if not browser.setup():
|
||||||
|
print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and")
|
||||||
|
print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.")
|
||||||
|
sys.exit(0)
|
||||||
|
browser.teardown()
|
||||||
|
|
||||||
browser = BrowserTest(headless=False) # Set to True for headless mode
|
browser = BrowserTest(headless=False) # Set to True for headless mode
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Run browser tests
|
# Run browser tests
|
||||||
results.append(("Browser Connection", test_browser_connection(browser)))
|
results.append(("Browser Connection", test_browser_connection(browser)))
|
||||||
results.append(("Tabs UI", test_tabs_ui(browser)))
|
results.append(("Tabs UI", test_tabs_ui(browser)))
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
|
|||||||
'/static/tabs.js',
|
'/static/tabs.js',
|
||||||
'/static/presets.js',
|
'/static/presets.js',
|
||||||
'/static/profiles.js',
|
'/static/profiles.js',
|
||||||
|
'/static/devices.js',
|
||||||
]
|
]
|
||||||
|
|
||||||
for file_path in static_files:
|
for file_path in static_files:
|
||||||
|
|||||||
Reference in New Issue
Block a user