Compare commits
4 Commits
fd618d7714
...
pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ddd559c9 | ||
|
|
5a1067263a | ||
|
|
e67de6215a | ||
|
|
7179b6531e |
@@ -1 +1 @@
|
|||||||
{"aabbccddeeff": {"id": "aabbccddeeff", "name": "one", "type": "led", "transport": "espnow", "address": "aabbccddeeff", "default_pattern": null, "zones": []}, "f0f5bdfd78b8": {"id": "f0f5bdfd78b8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.215", "default_pattern": null, "zones": []}}
|
{"f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.182", "default_pattern": null, "zones": []}, "188b0e1560a8": {"id": "188b0e1560a8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.242", "default_pattern": null, "zones": []}, "24ec4acaffcc": {"id": "24ec4acaffcc", "name": "c", "type": "led", "transport": "wifi", "address": "10.1.1.171", "default_pattern": null, "zones": []}}
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "default", "names": ["e", "c", "d", "a"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "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"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
{"1": {"name": "default", "names": ["a", "c", "a"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "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"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||||
21
esp32/msg.json
Normal file
21
esp32/msg.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"ch": 6,
|
||||||
|
|
||||||
|
"peers": {
|
||||||
|
"12:3456789012":{
|
||||||
|
"select": [["name1", "preset1"]]
|
||||||
|
|
||||||
|
,
|
||||||
|
"ff:ff:ff:ff:ff:ff": {
|
||||||
|
"presets": {
|
||||||
|
"preset1": {
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"delay": 100,
|
||||||
|
"brightness": 127,
|
||||||
|
"auto": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule led-driver updated: cef9e00819...a64457a0d5
2
led-tool
2
led-tool
Submodule led-tool updated: e86312437c...5f7acf38f0
6
patterns/__init__.py
Normal file
6
patterns/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .blink import Blink
|
||||||
|
from .rainbow import Rainbow
|
||||||
|
from .pulse import Pulse
|
||||||
|
from .transition import Transition
|
||||||
|
from .chase import Chase
|
||||||
|
from .circle import Circle
|
||||||
33
patterns/blink.py
Normal file
33
patterns/blink.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Blink:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
|
||||||
|
# Use provided colors, or default to white if none
|
||||||
|
colors = preset.c if preset.c else [(255, 255, 255)]
|
||||||
|
color_index = 0
|
||||||
|
state = True # True = on, False = off
|
||||||
|
last_update = utime.ticks_ms()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_time = utime.ticks_ms()
|
||||||
|
# Re-read delay each loop so live updates to preset.d take effect
|
||||||
|
delay_ms = max(1, int(preset.d))
|
||||||
|
if utime.ticks_diff(current_time, last_update) >= delay_ms:
|
||||||
|
if state:
|
||||||
|
base_color = colors[color_index % len(colors)]
|
||||||
|
color = self.driver.apply_brightness(base_color, preset.b)
|
||||||
|
self.driver.fill(color)
|
||||||
|
# Advance to next color for the next "on" phase
|
||||||
|
color_index += 1
|
||||||
|
else:
|
||||||
|
# "Off" phase: turn all LEDs off
|
||||||
|
self.driver.fill((0, 0, 0))
|
||||||
|
state = not state
|
||||||
|
last_update = current_time
|
||||||
|
# Yield once per tick so other logic can run
|
||||||
|
yield
|
||||||
124
patterns/chase.py
Normal file
124
patterns/chase.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Chase:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
|
||||||
|
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
|
||||||
|
colors = preset.c
|
||||||
|
if len(colors) < 1:
|
||||||
|
# Need at least 1 color
|
||||||
|
return
|
||||||
|
|
||||||
|
# Access colors, delay, and n values from preset
|
||||||
|
if not colors:
|
||||||
|
return
|
||||||
|
# If only one color provided, use it for both colors
|
||||||
|
if len(colors) < 2:
|
||||||
|
color0 = colors[0]
|
||||||
|
color1 = colors[0]
|
||||||
|
else:
|
||||||
|
color0 = colors[0]
|
||||||
|
color1 = colors[1]
|
||||||
|
|
||||||
|
color0 = self.driver.apply_brightness(color0, preset.b)
|
||||||
|
color1 = self.driver.apply_brightness(color1, preset.b)
|
||||||
|
|
||||||
|
n1 = max(1, int(preset.n1)) # LEDs of color 0
|
||||||
|
n2 = max(1, int(preset.n2)) # LEDs of color 1
|
||||||
|
n3 = int(preset.n3) # Step movement on even steps (can be negative)
|
||||||
|
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
|
||||||
|
|
||||||
|
segment_length = n1 + n2
|
||||||
|
|
||||||
|
# Calculate position from step_count
|
||||||
|
step_count = self.driver.step
|
||||||
|
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
|
||||||
|
if step_count % 2 == 0:
|
||||||
|
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
|
||||||
|
position = (step_count // 2) * (n3 + n4) + n3
|
||||||
|
else:
|
||||||
|
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
|
||||||
|
position = ((step_count + 1) // 2) * (n3 + n4)
|
||||||
|
|
||||||
|
# Wrap position to keep it reasonable
|
||||||
|
max_pos = self.driver.num_leds + segment_length
|
||||||
|
position = position % max_pos
|
||||||
|
if position < 0:
|
||||||
|
position += max_pos
|
||||||
|
|
||||||
|
# If auto is False, run a single step and then stop
|
||||||
|
if not preset.a:
|
||||||
|
# Clear all LEDs
|
||||||
|
self.driver.n.fill((0, 0, 0))
|
||||||
|
|
||||||
|
# Draw repeating pattern starting at position
|
||||||
|
for i in range(self.driver.num_leds):
|
||||||
|
# Calculate position in the repeating segment
|
||||||
|
relative_pos = (i - position) % segment_length
|
||||||
|
if relative_pos < 0:
|
||||||
|
relative_pos = (relative_pos + segment_length) % segment_length
|
||||||
|
|
||||||
|
# Determine which color based on position in segment
|
||||||
|
if relative_pos < n1:
|
||||||
|
self.driver.n[i] = color0
|
||||||
|
else:
|
||||||
|
self.driver.n[i] = color1
|
||||||
|
|
||||||
|
self.driver.n.write()
|
||||||
|
|
||||||
|
# Increment step for next beat
|
||||||
|
self.driver.step = step_count + 1
|
||||||
|
|
||||||
|
# Allow tick() to advance the generator once
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
# Auto mode: continuous loop
|
||||||
|
# Use transition_duration for timing and force the first update to happen immediately
|
||||||
|
transition_duration = max(10, int(preset.d))
|
||||||
|
last_update = utime.ticks_ms() - transition_duration
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_time = utime.ticks_ms()
|
||||||
|
if utime.ticks_diff(current_time, last_update) >= transition_duration:
|
||||||
|
# Calculate current position from step_count
|
||||||
|
if step_count % 2 == 0:
|
||||||
|
position = (step_count // 2) * (n3 + n4) + n3
|
||||||
|
else:
|
||||||
|
position = ((step_count + 1) // 2) * (n3 + n4)
|
||||||
|
|
||||||
|
# Wrap position
|
||||||
|
max_pos = self.driver.num_leds + segment_length
|
||||||
|
position = position % max_pos
|
||||||
|
if position < 0:
|
||||||
|
position += max_pos
|
||||||
|
|
||||||
|
# Clear all LEDs
|
||||||
|
self.driver.n.fill((0, 0, 0))
|
||||||
|
|
||||||
|
# Draw repeating pattern starting at position
|
||||||
|
for i in range(self.driver.num_leds):
|
||||||
|
# Calculate position in the repeating segment
|
||||||
|
relative_pos = (i - position) % segment_length
|
||||||
|
if relative_pos < 0:
|
||||||
|
relative_pos = (relative_pos + segment_length) % segment_length
|
||||||
|
|
||||||
|
# Determine which color based on position in segment
|
||||||
|
if relative_pos < n1:
|
||||||
|
self.driver.n[i] = color0
|
||||||
|
else:
|
||||||
|
self.driver.n[i] = color1
|
||||||
|
|
||||||
|
self.driver.n.write()
|
||||||
|
|
||||||
|
# Increment step
|
||||||
|
step_count += 1
|
||||||
|
self.driver.step = step_count
|
||||||
|
last_update = current_time
|
||||||
|
|
||||||
|
# Yield once per tick so other logic can run
|
||||||
|
yield
|
||||||
96
patterns/circle.py
Normal file
96
patterns/circle.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Circle:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
|
||||||
|
head = 0
|
||||||
|
tail = 0
|
||||||
|
|
||||||
|
# Calculate timing from preset
|
||||||
|
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
|
||||||
|
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
|
||||||
|
max_length = max(1, int(preset.n2)) # n2 = max length
|
||||||
|
min_length = max(0, int(preset.n4)) # n4 = min length
|
||||||
|
|
||||||
|
head_delay = 1000 // head_rate # ms between head movements
|
||||||
|
tail_delay = 1000 // tail_rate # ms between tail movements
|
||||||
|
|
||||||
|
last_head_move = utime.ticks_ms()
|
||||||
|
last_tail_move = utime.ticks_ms()
|
||||||
|
|
||||||
|
phase = "growing" # "growing", "shrinking", or "off"
|
||||||
|
|
||||||
|
# Support up to two colors (like chase). If only one color is provided,
|
||||||
|
# use black for the second; if none, default to white.
|
||||||
|
colors = preset.c
|
||||||
|
if not colors:
|
||||||
|
base0 = base1 = (255, 255, 255)
|
||||||
|
elif len(colors) == 1:
|
||||||
|
base0 = colors[0]
|
||||||
|
base1 = (0, 0, 0)
|
||||||
|
else:
|
||||||
|
base0 = colors[0]
|
||||||
|
base1 = colors[1]
|
||||||
|
|
||||||
|
color0 = self.driver.apply_brightness(base0, preset.b)
|
||||||
|
color1 = self.driver.apply_brightness(base1, preset.b)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_time = utime.ticks_ms()
|
||||||
|
|
||||||
|
# Background: use second color during the "off" phase, otherwise clear to black
|
||||||
|
if phase == "off":
|
||||||
|
self.driver.n.fill(color1)
|
||||||
|
else:
|
||||||
|
self.driver.n.fill((0, 0, 0))
|
||||||
|
|
||||||
|
# Calculate segment length
|
||||||
|
segment_length = (head - tail) % self.driver.num_leds
|
||||||
|
if segment_length == 0 and head != tail:
|
||||||
|
segment_length = self.driver.num_leds
|
||||||
|
|
||||||
|
# Draw segment from tail to head as a solid color (no per-LED alternation)
|
||||||
|
current_color = color0
|
||||||
|
for i in range(segment_length + 1):
|
||||||
|
led_pos = (tail + i) % self.driver.num_leds
|
||||||
|
self.driver.n[led_pos] = current_color
|
||||||
|
|
||||||
|
# Move head continuously at n1 LEDs per second
|
||||||
|
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
|
||||||
|
head = (head + 1) % self.driver.num_leds
|
||||||
|
last_head_move = current_time
|
||||||
|
|
||||||
|
# Tail behavior based on phase
|
||||||
|
if phase == "growing":
|
||||||
|
# Growing phase: tail stays at 0 until max length reached
|
||||||
|
if segment_length >= max_length:
|
||||||
|
phase = "shrinking"
|
||||||
|
elif phase == "shrinking":
|
||||||
|
# Shrinking phase: move tail forward at n3 LEDs per second
|
||||||
|
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
|
||||||
|
tail = (tail + 1) % self.driver.num_leds
|
||||||
|
last_tail_move = current_time
|
||||||
|
|
||||||
|
# Check if we've reached min length
|
||||||
|
current_length = (head - tail) % self.driver.num_leds
|
||||||
|
if current_length == 0 and head != tail:
|
||||||
|
current_length = self.driver.num_leds
|
||||||
|
|
||||||
|
# For min_length = 0, we need at least 1 LED (the head)
|
||||||
|
if min_length == 0 and current_length <= 1:
|
||||||
|
phase = "off" # All LEDs off for 1 step
|
||||||
|
elif min_length > 0 and current_length <= min_length:
|
||||||
|
phase = "growing" # Cycle repeats
|
||||||
|
else: # phase == "off"
|
||||||
|
# Off phase: second color fills the ring for 1 step, then restart
|
||||||
|
tail = head # Reset tail to head position to start fresh
|
||||||
|
phase = "growing"
|
||||||
|
|
||||||
|
self.driver.n.write()
|
||||||
|
|
||||||
|
# Yield once per tick so other logic can run
|
||||||
|
yield
|
||||||
64
patterns/pulse.py
Normal file
64
patterns/pulse.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Pulse:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
self.driver.off()
|
||||||
|
|
||||||
|
# Get colors from preset
|
||||||
|
colors = preset.c
|
||||||
|
if not colors:
|
||||||
|
colors = [(255, 255, 255)]
|
||||||
|
|
||||||
|
color_index = 0
|
||||||
|
cycle_start = utime.ticks_ms()
|
||||||
|
|
||||||
|
# State machine based pulse using a single generator loop
|
||||||
|
while True:
|
||||||
|
# Read current timing parameters from preset
|
||||||
|
attack_ms = max(0, int(preset.n1)) # Attack time in ms
|
||||||
|
hold_ms = max(0, int(preset.n2)) # Hold time in ms
|
||||||
|
decay_ms = max(0, int(preset.n3)) # Decay time in ms
|
||||||
|
delay_ms = max(0, int(preset.d))
|
||||||
|
|
||||||
|
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
|
||||||
|
if total_ms <= 0:
|
||||||
|
total_ms = 1
|
||||||
|
|
||||||
|
now = utime.ticks_ms()
|
||||||
|
elapsed = utime.ticks_diff(now, cycle_start)
|
||||||
|
|
||||||
|
base_color = colors[color_index % len(colors)]
|
||||||
|
|
||||||
|
if elapsed < attack_ms and attack_ms > 0:
|
||||||
|
# Attack: fade 0 -> 1
|
||||||
|
factor = elapsed / attack_ms
|
||||||
|
color = tuple(int(c * factor) for c in base_color)
|
||||||
|
self.driver.fill(self.driver.apply_brightness(color, preset.b))
|
||||||
|
elif elapsed < attack_ms + hold_ms:
|
||||||
|
# Hold: full brightness
|
||||||
|
self.driver.fill(self.driver.apply_brightness(base_color, preset.b))
|
||||||
|
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
|
||||||
|
# Decay: fade 1 -> 0
|
||||||
|
dec_elapsed = elapsed - attack_ms - hold_ms
|
||||||
|
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
|
||||||
|
color = tuple(int(c * factor) for c in base_color)
|
||||||
|
self.driver.fill(self.driver.apply_brightness(color, preset.b))
|
||||||
|
elif elapsed < total_ms:
|
||||||
|
# Delay phase: LEDs off between pulses
|
||||||
|
self.driver.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
# End of cycle, move to next color and restart timing
|
||||||
|
color_index += 1
|
||||||
|
cycle_start = now
|
||||||
|
if not preset.a:
|
||||||
|
break
|
||||||
|
# Skip drawing this tick, start next cycle
|
||||||
|
yield
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Yield once per tick
|
||||||
|
yield
|
||||||
51
patterns/rainbow.py
Normal file
51
patterns/rainbow.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Rainbow:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def _wheel(self, pos):
|
||||||
|
if pos < 85:
|
||||||
|
return (pos * 3, 255 - pos * 3, 0)
|
||||||
|
elif pos < 170:
|
||||||
|
pos -= 85
|
||||||
|
return (255 - pos * 3, 0, pos * 3)
|
||||||
|
else:
|
||||||
|
pos -= 170
|
||||||
|
return (0, pos * 3, 255 - pos * 3)
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
step = self.driver.step % 256
|
||||||
|
step_amount = max(1, int(preset.n1)) # n1 controls step increment
|
||||||
|
|
||||||
|
# If auto is False, run a single step and then stop
|
||||||
|
if not preset.a:
|
||||||
|
for i in range(self.driver.num_leds):
|
||||||
|
rc_index = (i * 256 // self.driver.num_leds) + step
|
||||||
|
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
|
||||||
|
self.driver.n.write()
|
||||||
|
# Increment step by n1 for next manual call
|
||||||
|
self.driver.step = (step + step_amount) % 256
|
||||||
|
# Allow tick() to advance the generator once
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
last_update = utime.ticks_ms()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
current_time = utime.ticks_ms()
|
||||||
|
sleep_ms = max(1, int(preset.d)) # Get delay from preset
|
||||||
|
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
|
||||||
|
for i in range(self.driver.num_leds):
|
||||||
|
rc_index = (i * 256 // self.driver.num_leds) + step
|
||||||
|
self.driver.n[i] = self.driver.apply_brightness(
|
||||||
|
self._wheel(rc_index & 255),
|
||||||
|
preset.b,
|
||||||
|
)
|
||||||
|
self.driver.n.write()
|
||||||
|
step = (step + step_amount) % 256
|
||||||
|
self.driver.step = step
|
||||||
|
last_update = current_time
|
||||||
|
# Yield once per tick so other logic can run
|
||||||
|
yield
|
||||||
57
patterns/transition.py
Normal file
57
patterns/transition.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import utime
|
||||||
|
|
||||||
|
|
||||||
|
class Transition:
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def run(self, preset):
|
||||||
|
"""Transition between colors, blending over `delay` ms."""
|
||||||
|
colors = preset.c
|
||||||
|
if not colors:
|
||||||
|
self.driver.off()
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only one color: just keep it on
|
||||||
|
if len(colors) == 1:
|
||||||
|
while True:
|
||||||
|
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
color_index = 0
|
||||||
|
start_time = utime.ticks_ms()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if not colors:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get current and next color based on live list
|
||||||
|
c1 = colors[color_index % len(colors)]
|
||||||
|
c2 = colors[(color_index + 1) % len(colors)]
|
||||||
|
|
||||||
|
duration = max(10, int(preset.d)) # At least 10ms
|
||||||
|
now = utime.ticks_ms()
|
||||||
|
elapsed = utime.ticks_diff(now, start_time)
|
||||||
|
|
||||||
|
if elapsed >= duration:
|
||||||
|
# End of this transition step
|
||||||
|
if not preset.a:
|
||||||
|
# One-shot: transition from first to second color only
|
||||||
|
self.driver.fill(self.driver.apply_brightness(c2, preset.b))
|
||||||
|
break
|
||||||
|
# Auto: move to next pair
|
||||||
|
color_index = (color_index + 1) % len(colors)
|
||||||
|
start_time = now
|
||||||
|
yield
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Interpolate between c1 and c2
|
||||||
|
factor = elapsed / duration
|
||||||
|
interpolated = tuple(
|
||||||
|
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
|
||||||
|
)
|
||||||
|
self.driver.fill(self.driver.apply_brightness(interpolated, preset.b))
|
||||||
|
|
||||||
|
yield
|
||||||
@@ -14,6 +14,7 @@ from models.tcp_clients import (
|
|||||||
from util.espnow_message import build_message
|
from util.espnow_message import build_message
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
|
||||||
_IDENTIFY_PRESET_KEY = "__identify"
|
_IDENTIFY_PRESET_KEY = "__identify"
|
||||||
@@ -72,6 +73,37 @@ def _device_json_with_live_status(dev_dict):
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _driver_patterns_dir():
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pattern_filename(name):
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return False
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
return False
|
||||||
|
if "/" in name or "\\" in name or ".." in name:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_patterns_manifest(host):
|
||||||
|
base_dir = _driver_patterns_dir()
|
||||||
|
names = sorted(os.listdir(base_dir))
|
||||||
|
files = []
|
||||||
|
for name in names:
|
||||||
|
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||||
|
continue
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"files": files}
|
||||||
|
|
||||||
|
|
||||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||||
@@ -259,3 +291,56 @@ async def identify_device(request, id):
|
|||||||
return json.dumps({"message": "Identify sent"}), 200, {
|
return json.dumps({"message": "Identify sent"}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/patterns/push")
|
||||||
|
async def push_patterns_ota(request, id):
|
||||||
|
"""
|
||||||
|
Ask a Wi-Fi LED driver to pull pattern files from this server over HTTP.
|
||||||
|
|
||||||
|
Body (optional):
|
||||||
|
{"manifest": "http://host:port/patterns/ota/manifest"}
|
||||||
|
"""
|
||||||
|
dev = devices.read(id)
|
||||||
|
if not dev:
|
||||||
|
return json.dumps({"error": "Device not found"}), 404, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if (dev.get("transport") or "").lower() != "wifi":
|
||||||
|
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
wifi_ip = str(dev.get("address") or "").strip()
|
||||||
|
if not wifi_ip:
|
||||||
|
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
body = request.json or {}
|
||||||
|
manifest_payload = body.get("manifest")
|
||||||
|
if manifest_payload is None:
|
||||||
|
host = request.headers.get("Host", "")
|
||||||
|
if not host:
|
||||||
|
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
manifest_payload = _build_patterns_manifest(host)
|
||||||
|
except OSError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if not isinstance(manifest_payload, (str, dict)):
|
||||||
|
return json.dumps({"error": "manifest must be a URL string or manifest object"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = json.dumps({"v": "1", "manifest": manifest_payload}, separators=(",", ":"))
|
||||||
|
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
return json.dumps({"message": "Pattern OTA trigger sent", "manifest": manifest_payload}), 200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from models.pattern import Pattern
|
from models.pattern import Pattern
|
||||||
|
from models.device import Device
|
||||||
|
from models.tcp_clients import send_json_line_to_ip
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
patterns = Pattern()
|
patterns = Pattern()
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root():
|
||||||
|
"""Project root (parent of ``src/``). CWD is often ``src/`` when running ``main.py``."""
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
return os.path.abspath(os.path.join(here, "..", ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _driver_patterns_dir():
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pattern_filename(name):
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return False
|
||||||
|
if not name.endswith(".py"):
|
||||||
|
return False
|
||||||
|
if "/" in name or "\\" in name or ".." in name:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_PATTERN_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_pattern_key(raw):
|
||||||
|
"""Pattern id / module basename (no .py)."""
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return ""
|
||||||
|
s = raw.strip()
|
||||||
|
if s.lower().endswith(".py"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_pattern_key(key):
|
||||||
|
return bool(key and _PATTERN_KEY_RE.match(key))
|
||||||
|
|
||||||
def load_pattern_definitions():
|
def load_pattern_definitions():
|
||||||
"""Load pattern definitions from pattern.json file."""
|
"""Load pattern definitions from pattern.json file."""
|
||||||
try:
|
try:
|
||||||
# Try different paths for local development vs MicroPython
|
root = _project_root()
|
||||||
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
|
paths = [
|
||||||
|
os.path.join(root, "db", "pattern.json"),
|
||||||
|
os.path.join(root, "pattern.json"),
|
||||||
|
"db/pattern.json",
|
||||||
|
"pattern.json",
|
||||||
|
"/db/pattern.json",
|
||||||
|
]
|
||||||
for path in paths:
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
with open(path, 'r') as f:
|
with open(path, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
@@ -22,16 +70,301 @@ def load_pattern_definitions():
|
|||||||
print(f"Error loading pattern.json: {e}")
|
print(f"Error loading pattern.json: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_driver_pattern_names():
|
||||||
|
"""List available pattern module names from led-driver/src/patterns."""
|
||||||
|
try:
|
||||||
|
names = []
|
||||||
|
for filename in os.listdir(_driver_patterns_dir()):
|
||||||
|
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||||
|
continue
|
||||||
|
names.append(filename[:-3])
|
||||||
|
names.sort()
|
||||||
|
return names
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_pattern_map():
|
||||||
|
"""
|
||||||
|
Runtime pattern map for UI menus.
|
||||||
|
Keep pattern DB metadata as primary, then add any local driver pattern files
|
||||||
|
missing from the DB so new OTA files still appear in menus.
|
||||||
|
"""
|
||||||
|
definitions = load_pattern_definitions()
|
||||||
|
available = load_driver_pattern_names()
|
||||||
|
result = {}
|
||||||
|
for name, meta in definitions.items():
|
||||||
|
result[name] = dict(meta) if isinstance(meta, dict) else {}
|
||||||
|
for name in available:
|
||||||
|
if name not in result:
|
||||||
|
result[name] = {}
|
||||||
|
return result
|
||||||
|
|
||||||
@controller.get('/definitions')
|
@controller.get('/definitions')
|
||||||
async def get_pattern_definitions(request):
|
async def get_pattern_definitions(request):
|
||||||
"""Get pattern definitions from pattern.json."""
|
"""Get definitions for patterns currently available on the driver."""
|
||||||
definitions = load_pattern_definitions()
|
definitions = build_runtime_pattern_map()
|
||||||
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/ota/manifest')
|
||||||
|
async def ota_manifest(request):
|
||||||
|
"""Manifest of driver pattern source files for OTA pulls."""
|
||||||
|
base_dir = _driver_patterns_dir()
|
||||||
|
host = request.headers.get("Host", "")
|
||||||
|
if not host:
|
||||||
|
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(base_dir))
|
||||||
|
except OSError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for name in names:
|
||||||
|
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||||
|
continue
|
||||||
|
files.append({
|
||||||
|
"name": name,
|
||||||
|
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||||
|
})
|
||||||
|
|
||||||
|
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/ota/file/<name>')
|
||||||
|
async def ota_pattern_file(request, name):
|
||||||
|
"""Serve one driver pattern source file for OTA pulls."""
|
||||||
|
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||||
|
return json.dumps({"error": "Invalid filename"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
path = os.path.join(_driver_patterns_dir(), name)
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError:
|
||||||
|
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/<name>/send')
|
||||||
|
async def send_pattern_to_device(request, name):
|
||||||
|
"""Tell Wi-Fi driver(s) to download one pattern source file over HTTP."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
filename = name if name.endswith(".py") else (name + ".py")
|
||||||
|
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||||
|
return json.dumps({"error": "Invalid pattern filename"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = Device()
|
||||||
|
body = request.json or {}
|
||||||
|
requested_device_id = str(body.get("device_id") or "").strip()
|
||||||
|
|
||||||
|
path = os.path.join(_driver_patterns_dir(), filename)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
file_url = "/patterns/ota/file/%s" % filename
|
||||||
|
|
||||||
|
msg = json.dumps(
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"manifest": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": filename,
|
||||||
|
"url": file_url,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
target_ids = []
|
||||||
|
if requested_device_id:
|
||||||
|
dev = devices.read(requested_device_id)
|
||||||
|
if not dev:
|
||||||
|
return json.dumps({"error": "Device not found"}), 404, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
if (dev.get("transport") or "").lower() != "wifi":
|
||||||
|
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
target_ids = [requested_device_id]
|
||||||
|
else:
|
||||||
|
for did in devices.list():
|
||||||
|
dev = devices.read(did) or {}
|
||||||
|
if (dev.get("transport") or "").lower() == "wifi":
|
||||||
|
target_ids.append(str(did))
|
||||||
|
if not target_ids:
|
||||||
|
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
sent_ids = []
|
||||||
|
for did in target_ids:
|
||||||
|
dev = devices.read(did) or {}
|
||||||
|
ip = str(dev.get("address") or "").strip()
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
ok = await send_json_line_to_ip(ip, msg)
|
||||||
|
if ok:
|
||||||
|
sent_ids.append(did)
|
||||||
|
|
||||||
|
if not sent_ids:
|
||||||
|
return json.dumps({"error": "No Wi-Fi drivers connected"}), 503, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/upload')
|
||||||
|
async def upload_pattern_file(request):
|
||||||
|
"""
|
||||||
|
Upload a pattern source file to led-controller local storage.
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
{
|
||||||
|
"name": "sparkle.py" | "sparkle",
|
||||||
|
"code": "class Sparkle: ...",
|
||||||
|
"overwrite": true | false # optional, default true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
raw_name = data.get("name") or data.get("filename")
|
||||||
|
code = data.get("code")
|
||||||
|
overwrite = data.get("overwrite", True)
|
||||||
|
overwrite = bool(overwrite)
|
||||||
|
|
||||||
|
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||||
|
return json.dumps({"error": "name is required"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
filename = raw_name.strip()
|
||||||
|
if not filename.endswith(".py"):
|
||||||
|
filename += ".py"
|
||||||
|
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||||
|
return json.dumps({"error": "invalid pattern filename"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
if not isinstance(code, str) or not code.strip():
|
||||||
|
return json.dumps({"error": "code is required"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
path = os.path.join(_driver_patterns_dir(), filename)
|
||||||
|
exists = os.path.exists(path)
|
||||||
|
if exists and not overwrite:
|
||||||
|
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
except OSError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "Pattern uploaded",
|
||||||
|
"name": filename,
|
||||||
|
"overwrote": bool(exists),
|
||||||
|
}), 201, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/driver')
|
||||||
|
async def create_driver_pattern(request):
|
||||||
|
"""
|
||||||
|
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
|
||||||
|
metadata in db/pattern.json (Pattern model).
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
name, code (required),
|
||||||
|
min_delay, max_delay, max_colors (optional numbers),
|
||||||
|
n1..n8 (optional string labels),
|
||||||
|
overwrite (optional, default true).
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
key = _normalize_pattern_key(data.get("name") or "")
|
||||||
|
if not _valid_pattern_key(key):
|
||||||
|
return json.dumps({
|
||||||
|
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
|
||||||
|
}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
code = data.get("code")
|
||||||
|
if not isinstance(code, str) or not code.strip():
|
||||||
|
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
overwrite = bool(data.get("overwrite", True))
|
||||||
|
|
||||||
|
filename = key + ".py"
|
||||||
|
py_path = os.path.join(_driver_patterns_dir(), filename)
|
||||||
|
if os.path.exists(py_path) and not overwrite:
|
||||||
|
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = {}
|
||||||
|
for fld in ("min_delay", "max_delay", "max_colors"):
|
||||||
|
if fld not in data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta[fld] = int(data[fld])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in range(1, 9):
|
||||||
|
nk = "n%d" % i
|
||||||
|
if nk not in data:
|
||||||
|
continue
|
||||||
|
lab = data[nk]
|
||||||
|
if lab is None:
|
||||||
|
continue
|
||||||
|
s = str(lab).strip()
|
||||||
|
if s:
|
||||||
|
meta[nk] = s
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(py_path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
except OSError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
if patterns.read(key):
|
||||||
|
patterns.update(key, meta)
|
||||||
|
else:
|
||||||
|
patterns.create(key, meta)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "Pattern created",
|
||||||
|
"name": key,
|
||||||
|
"file": filename,
|
||||||
|
"metadata": patterns.read(key),
|
||||||
|
}), 201, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def list_patterns(request):
|
async def list_patterns(request):
|
||||||
"""List all patterns."""
|
"""List patterns for UI (DB metadata + local driver additions)."""
|
||||||
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
|
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
|
|||||||
156
src/main.py
156
src/main.py
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
@@ -35,6 +36,7 @@ _tcp_device_lock = threading.Lock()
|
|||||||
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
|
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
|
||||||
# fail drain() within this interval (keepalive alone is often slow or ineffective).
|
# fail drain() within this interval (keepalive alone is often slow or ineffective).
|
||||||
TCP_LIVENESS_PING_INTERVAL_S = 12.0
|
TCP_LIVENESS_PING_INTERVAL_S = 12.0
|
||||||
|
DISCOVERY_UDP_PORT = 8766
|
||||||
|
|
||||||
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
|
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
|
||||||
_TCP_PEER_GONE = (
|
_TCP_PEER_GONE = (
|
||||||
@@ -108,7 +110,7 @@ async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def _register_tcp_device_sync(
|
def _register_udp_device_sync(
|
||||||
device_name: str, peer_ip: str, mac, device_type=None
|
device_name: str, peer_ip: str, mac, device_type=None
|
||||||
) -> None:
|
) -> None:
|
||||||
with _tcp_device_lock:
|
with _tcp_device_lock:
|
||||||
@@ -119,13 +121,74 @@ def _register_tcp_device_sync(
|
|||||||
)
|
)
|
||||||
if did:
|
if did:
|
||||||
print(
|
print(
|
||||||
f"TCP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"TCP device registry failed: {e}")
|
print(f"UDP device registry failed: {e}")
|
||||||
traceback.print_exception(type(e), e, e.__traceback__)
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
if udp_holder and udp_holder.get("closing"):
|
||||||
|
break
|
||||||
|
print(f"[UDP] recv failed: {e!r}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UDP] recv failed: {e!r}")
|
||||||
|
continue
|
||||||
|
peer_ip = addr[0] if addr else ""
|
||||||
|
line = data.split(b"\n", 1)[0].strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(line.decode("utf-8"))
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
dns = str(parsed.get("device_name") or "").strip()
|
||||||
|
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
|
||||||
|
"sta_mac"
|
||||||
|
)
|
||||||
|
device_type = parsed.get("type") or parsed.get("device_type")
|
||||||
|
if dns and normalize_mac(mac):
|
||||||
|
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
||||||
|
except (UnicodeError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UDP] echo send failed: {e!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setblocking(False)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
|
||||||
|
if udp_holder is not None:
|
||||||
|
udp_holder["sock"] = sock
|
||||||
|
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
||||||
|
try:
|
||||||
|
await _handle_udp_discovery(sock, udp_holder)
|
||||||
|
finally:
|
||||||
|
if udp_holder is not None:
|
||||||
|
udp_holder.pop("sock", None)
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _handle_tcp_client(reader, writer):
|
async def _handle_tcp_client(reader, writer):
|
||||||
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
|
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
|
||||||
peer = writer.get_extra_info("peername")
|
peer = writer.get_extra_info("peername")
|
||||||
@@ -173,13 +236,6 @@ async def _handle_tcp_client(reader, writer):
|
|||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
dns = str(parsed.get("device_name") or "").strip()
|
|
||||||
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get("sta_mac")
|
|
||||||
device_type = parsed.get("type") or parsed.get("device_type")
|
|
||||||
if dns and normalize_mac(mac):
|
|
||||||
_register_tcp_device_sync(
|
|
||||||
dns, peer_ip, mac, device_type=device_type
|
|
||||||
)
|
|
||||||
addr = parsed.pop("to", None)
|
addr = parsed.pop("to", None)
|
||||||
payload = json.dumps(parsed) if parsed else "{}"
|
payload = json.dumps(parsed) if parsed else "{}"
|
||||||
if sender:
|
if sender:
|
||||||
@@ -229,15 +285,21 @@ async def _send_bridge_wifi_channel(settings, sender):
|
|||||||
print(f"[startup] bridge channel message failed: {e}")
|
print(f"[startup] bridge channel message failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _run_tcp_server(settings):
|
async def _run_tcp_server(settings, tcp_holder=None):
|
||||||
if not settings.get("tcp_enabled", True):
|
if not settings.get("tcp_enabled", True):
|
||||||
print("TCP server disabled (tcp_enabled=false)")
|
print("TCP server disabled (tcp_enabled=false)")
|
||||||
return
|
return
|
||||||
port = int(settings.get("tcp_port", 8765))
|
port = int(settings.get("tcp_port", 8765))
|
||||||
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
|
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
|
||||||
print(f"TCP server listening on 0.0.0.0:{port}")
|
print(f"TCP server listening on 0.0.0.0:{port}")
|
||||||
async with server:
|
if tcp_holder is not None:
|
||||||
await server.serve_forever()
|
tcp_holder["server"] = server
|
||||||
|
try:
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
finally:
|
||||||
|
if tcp_holder is not None:
|
||||||
|
tcp_holder.pop("server", None)
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
@@ -349,24 +411,60 @@ async def main(port=80):
|
|||||||
Device()
|
Device()
|
||||||
await _send_bridge_wifi_channel(settings, sender)
|
await _send_bridge_wifi_channel(settings, sender)
|
||||||
|
|
||||||
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
|
tcp_holder = {}
|
||||||
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
|
udp_holder = {"closing": False}
|
||||||
# never starts, which clears Wi-Fi presence dots.
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def _graceful_shutdown(*_args):
|
||||||
|
print("[server] shutting down...")
|
||||||
|
udp_holder["closing"] = True
|
||||||
|
u = udp_holder.get("sock")
|
||||||
|
if u is not None:
|
||||||
|
try:
|
||||||
|
u.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
s = tcp_holder.get("server")
|
||||||
|
if s is not None:
|
||||||
|
s.close()
|
||||||
|
if getattr(app, "server", None) is not None:
|
||||||
|
app.shutdown()
|
||||||
|
|
||||||
|
shutdown_handlers_registered = False
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
try:
|
||||||
app.start_server(host="0.0.0.0", port=port),
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
_run_tcp_server(settings),
|
loop.add_signal_handler(sig, _graceful_shutdown)
|
||||||
)
|
shutdown_handlers_registered = True
|
||||||
except OSError as e:
|
except (NotImplementedError, RuntimeError):
|
||||||
if e.errno == errno.EADDRINUSE:
|
pass
|
||||||
tcp_p = int(settings.get("tcp_port", 8765))
|
|
||||||
print(
|
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
|
||||||
f"[server] bind failed (address already in use): {e!s}\n"
|
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
|
||||||
f"[server] HTTP is configured for port {port} (env PORT); "
|
# never starts, which clears Wi-Fi presence dots.
|
||||||
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
|
try:
|
||||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
await asyncio.gather(
|
||||||
|
app.start_server(host="0.0.0.0", port=port),
|
||||||
|
_run_tcp_server(settings, tcp_holder),
|
||||||
|
_run_udp_discovery_server(udp_holder),
|
||||||
)
|
)
|
||||||
raise
|
except OSError as e:
|
||||||
|
if e.errno == errno.EADDRINUSE:
|
||||||
|
tcp_p = int(settings.get("tcp_port", 8765))
|
||||||
|
print(
|
||||||
|
f"[server] bind failed (address already in use): {e!s}\n"
|
||||||
|
f"[server] HTTP is configured for port {port} (env PORT); "
|
||||||
|
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
|
||||||
|
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if shutdown_handlers_registered:
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.remove_signal_handler(sig)
|
||||||
|
except (NotImplementedError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import os
|
import os
|
||||||
|
|||||||
125
src/models/http_driver.py
Normal file
125
src/models/http_driver.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Wi-Fi LED drivers over HTTP long-poll (same port as the web UI).
|
||||||
|
|
||||||
|
Drivers POST /driver/v1/poll; the controller responds with queued JSON lines.
|
||||||
|
Presence: last poll within DRIVER_HTTP_SEEN_S counts as connected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from models.wifi_peer import normalize_wifi_peer_ip
|
||||||
|
|
||||||
|
# Must exceed max ``wait_s`` (60) on /driver/v1/poll so sessions are not pruned mid-wait.
|
||||||
|
DRIVER_HTTP_SEEN_S = 90.0
|
||||||
|
_QUEUE_MAX = 64
|
||||||
|
|
||||||
|
_queues: dict[str, asyncio.Queue] = {}
|
||||||
|
_last_poll: dict[str, float] = {}
|
||||||
|
_connected_flag: set[str] = set()
|
||||||
|
_status_broadcast = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_wifi_driver_status_broadcaster(coro) -> None:
|
||||||
|
global _status_broadcast
|
||||||
|
_status_broadcast = coro
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_status(ip: str, connected: bool) -> None:
|
||||||
|
fn = _status_broadcast
|
||||||
|
if not fn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop.create_task(fn(ip, connected))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_queue(ip: str) -> asyncio.Queue:
|
||||||
|
q = _queues.get(ip)
|
||||||
|
if q is None:
|
||||||
|
q = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||||
|
_queues[ip] = q
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def prune_stale_http_sessions() -> None:
|
||||||
|
"""Drop timed-out sessions, clear queues, broadcast disconnect."""
|
||||||
|
now = time.monotonic()
|
||||||
|
for ip in list(_last_poll.keys()):
|
||||||
|
if now - _last_poll[ip] <= DRIVER_HTTP_SEEN_S:
|
||||||
|
continue
|
||||||
|
_last_poll.pop(ip, None)
|
||||||
|
_queues.pop(ip, None)
|
||||||
|
if ip in _connected_flag:
|
||||||
|
_connected_flag.discard(ip)
|
||||||
|
_schedule_status(ip, False)
|
||||||
|
print(f"[HTTP driver] session timed out: {ip}")
|
||||||
|
|
||||||
|
|
||||||
|
def touch_http_session(ip: str) -> None:
|
||||||
|
ip = normalize_wifi_peer_ip(ip)
|
||||||
|
if not ip:
|
||||||
|
return
|
||||||
|
prune_stale_http_sessions()
|
||||||
|
now = time.monotonic()
|
||||||
|
_last_poll[ip] = now
|
||||||
|
if ip not in _connected_flag:
|
||||||
|
_connected_flag.add(ip)
|
||||||
|
_schedule_status(ip, True)
|
||||||
|
|
||||||
|
|
||||||
|
def wifi_driver_connected(ip: str) -> bool:
|
||||||
|
prune_stale_http_sessions()
|
||||||
|
key = normalize_wifi_peer_ip(ip)
|
||||||
|
return bool(key and key in _connected_flag)
|
||||||
|
|
||||||
|
|
||||||
|
def list_connected_driver_ips():
|
||||||
|
prune_stale_http_sessions()
|
||||||
|
return list(_connected_flag)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_json_line(ip: str, json_str: str) -> bool:
|
||||||
|
ip = normalize_wifi_peer_ip(ip)
|
||||||
|
if not ip:
|
||||||
|
return False
|
||||||
|
line = json_str[:-1] if json_str.endswith("\n") else json_str
|
||||||
|
q = _get_queue(ip)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
q.put_nowait(line)
|
||||||
|
return True
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||||
|
"""Queue one JSON line for the driver to receive on the next long-poll."""
|
||||||
|
return await enqueue_json_line(ip, json_str)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_lines_after_touch(ip: str, wait_s: float) -> list[str]:
|
||||||
|
"""Wait up to wait_s for first line, then drain the rest (non-blocking)."""
|
||||||
|
ip = normalize_wifi_peer_ip(ip)
|
||||||
|
if not ip:
|
||||||
|
return []
|
||||||
|
q = _get_queue(ip)
|
||||||
|
lines: list[str] = []
|
||||||
|
try:
|
||||||
|
first = await asyncio.wait_for(q.get(), timeout=wait_s)
|
||||||
|
lines.append(first)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
lines.append(q.get_nowait())
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
return lines
|
||||||
8
src/models/wifi_peer.py
Normal file
8
src/models/wifi_peer.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Normalise Wi-Fi client addresses (strip IPv4-mapped IPv6 prefix)."""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_wifi_peer_ip(ip: str) -> str:
|
||||||
|
s = str(ip).strip()
|
||||||
|
if s.lower().startswith("::ffff:"):
|
||||||
|
s = s[7:]
|
||||||
|
return s
|
||||||
@@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const patternsModal = document.getElementById('patterns-modal');
|
const patternsModal = document.getElementById('patterns-modal');
|
||||||
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
const patternsCloseButton = document.getElementById('patterns-close-btn');
|
||||||
const patternsList = document.getElementById('patterns-list');
|
const patternsList = document.getElementById('patterns-list');
|
||||||
|
const patternAddButton = document.getElementById('pattern-add-btn');
|
||||||
|
const patternEditorModal = document.getElementById('pattern-editor-modal');
|
||||||
|
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
|
||||||
|
const patternCreateBtn = document.getElementById('pattern-create-btn');
|
||||||
|
const patternCreateName = document.getElementById('pattern-create-name');
|
||||||
|
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
|
||||||
|
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
|
||||||
|
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
|
||||||
|
const patternCreateFile = document.getElementById('pattern-create-file');
|
||||||
|
const patternCreateCode = document.getElementById('pattern-create-code');
|
||||||
|
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
|
||||||
|
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
|
||||||
|
document.getElementById(`pattern-create-n${i}`),
|
||||||
|
);
|
||||||
|
const patternCreateNSection = document.getElementById('pattern-create-n-section');
|
||||||
|
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
|
||||||
|
|
||||||
if (!patternsButton || !patternsModal || !patternsList) {
|
if (!patternsButton || !patternsModal || !patternsList) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nReadableStringFromMeta = (meta, key) => {
|
||||||
|
if (!meta || typeof meta !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const pm = meta.parameter_mappings;
|
||||||
|
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
|
||||||
|
const s = pm[key].trim();
|
||||||
|
if (s) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof meta[key] === 'string') {
|
||||||
|
return meta[key].trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPatternEditorNFields = (mode, data) => {
|
||||||
|
const meta = data && typeof data === 'object' ? data : {};
|
||||||
|
let visible = 0;
|
||||||
|
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
|
||||||
|
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
|
||||||
|
|
||||||
|
for (let i = 1; i <= 8; i += 1) {
|
||||||
|
const key = `n${i}`;
|
||||||
|
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
|
||||||
|
const inputEl = document.getElementById(`pattern-create-${key}`);
|
||||||
|
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||||
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = '';
|
||||||
|
labelEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = '';
|
||||||
|
inputEl.placeholder = 'Readable name (optional)';
|
||||||
|
inputEl.removeAttribute('aria-label');
|
||||||
|
}
|
||||||
|
if (groupEl) {
|
||||||
|
groupEl.style.display = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readable = nReadableStringFromMeta(meta, key);
|
||||||
|
const show = Boolean(readable);
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = '';
|
||||||
|
labelEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.value = show ? readable : '';
|
||||||
|
inputEl.placeholder = '';
|
||||||
|
if (show) {
|
||||||
|
inputEl.setAttribute('aria-label', readable);
|
||||||
|
} else {
|
||||||
|
inputEl.removeAttribute('aria-label');
|
||||||
|
inputEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupEl) {
|
||||||
|
groupEl.style.display = show ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (show) {
|
||||||
|
visible += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
if (patternCreateNEmpty) {
|
||||||
|
patternCreateNEmpty.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (grid) {
|
||||||
|
grid.style.display = '';
|
||||||
|
}
|
||||||
|
if (h3) {
|
||||||
|
h3.style.display = '';
|
||||||
|
}
|
||||||
|
if (patternCreateNSection) {
|
||||||
|
patternCreateNSection.style.display = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patternCreateNEmpty) {
|
||||||
|
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (grid) {
|
||||||
|
grid.style.display = visible === 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (h3) {
|
||||||
|
h3.style.display = visible === 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileAsText = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectCreatePayload = async () => {
|
||||||
|
const name = patternCreateName ? patternCreateName.value.trim() : '';
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('Pattern name is required.');
|
||||||
|
}
|
||||||
|
let code = '';
|
||||||
|
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
|
||||||
|
if (fileInput) {
|
||||||
|
code = await readFileAsText(fileInput);
|
||||||
|
} else if (patternCreateCode && patternCreateCode.value.trim()) {
|
||||||
|
code = patternCreateCode.value;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
throw new Error('Choose a .py file or paste source code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
|
||||||
|
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
|
||||||
|
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
|
||||||
|
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
|
||||||
|
};
|
||||||
|
|
||||||
|
patternCreateN.forEach((el, idx) => {
|
||||||
|
const key = `n${idx + 1}`;
|
||||||
|
if (el && el.value.trim()) {
|
||||||
|
payload[key] = el.value.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCreateForm = () => {
|
||||||
|
if (patternCreateName) patternCreateName.value = '';
|
||||||
|
if (patternCreateFile) patternCreateFile.value = '';
|
||||||
|
if (patternCreateCode) patternCreateCode.value = '';
|
||||||
|
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
|
||||||
|
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
|
||||||
|
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
|
||||||
|
patternCreateN.forEach((el) => {
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
|
||||||
|
setPatternEditorNFields('create', {});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patternCreateBtn) {
|
||||||
|
patternCreateBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const payload = await collectCreatePayload();
|
||||||
|
const response = await fetch('/patterns/driver', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((data && data.error) || 'Create failed');
|
||||||
|
}
|
||||||
|
alert(data.message || 'Pattern created.');
|
||||||
|
resetCreateForm();
|
||||||
|
if (patternEditorModal) {
|
||||||
|
patternEditorModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
await loadPatterns();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Create pattern failed:', e);
|
||||||
|
alert(e.message || 'Failed to create pattern.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendPatternToDevices = async (patternName) => {
|
||||||
|
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((data && data.error) || 'Failed to send pattern');
|
||||||
|
}
|
||||||
|
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
|
||||||
|
if (sentCount === null) {
|
||||||
|
alert(`Sent "${patternName}" to devices.`);
|
||||||
|
} else {
|
||||||
|
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPatternMetadata = async (patternName, fallbackData) => {
|
||||||
|
const raw = String(patternName || '').trim();
|
||||||
|
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/patterns/definitions', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load pattern definitions');
|
||||||
|
}
|
||||||
|
const definitions = await response.json();
|
||||||
|
if (definitions && typeof definitions === 'object') {
|
||||||
|
if (definitions[raw]) {
|
||||||
|
return definitions[raw];
|
||||||
|
}
|
||||||
|
if (norm && definitions[norm]) {
|
||||||
|
return definitions[norm];
|
||||||
|
}
|
||||||
|
if (norm) {
|
||||||
|
const lower = norm.toLowerCase();
|
||||||
|
const matched = Object.keys(definitions).find(
|
||||||
|
(k) => String(k).toLowerCase() === lower,
|
||||||
|
);
|
||||||
|
if (matched) {
|
||||||
|
return definitions[matched];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load pattern definitions failed:', error);
|
||||||
|
}
|
||||||
|
return fallbackData || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPatternIntoEditor = async (patternName, fallbackData) => {
|
||||||
|
const data = await loadPatternMetadata(patternName, fallbackData);
|
||||||
|
if (patternCreateName) {
|
||||||
|
patternCreateName.value = patternName;
|
||||||
|
}
|
||||||
|
if (patternCreateMinDelay) {
|
||||||
|
patternCreateMinDelay.value =
|
||||||
|
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
|
||||||
|
}
|
||||||
|
if (patternCreateMaxDelay) {
|
||||||
|
patternCreateMaxDelay.value =
|
||||||
|
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
|
||||||
|
}
|
||||||
|
if (patternCreateMaxColors) {
|
||||||
|
patternCreateMaxColors.value =
|
||||||
|
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
|
||||||
|
}
|
||||||
|
setPatternEditorNFields('edit', data);
|
||||||
|
if (patternCreateOverwrite) {
|
||||||
|
patternCreateOverwrite.checked = true;
|
||||||
|
}
|
||||||
|
if (patternCreateFile) {
|
||||||
|
patternCreateFile.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
|
||||||
|
headers: { Accept: 'text/plain' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load pattern file');
|
||||||
|
}
|
||||||
|
const source = await response.text();
|
||||||
|
if (patternCreateCode) {
|
||||||
|
patternCreateCode.value = source || '';
|
||||||
|
patternCreateCode.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load pattern source failed:', error);
|
||||||
|
alert('Could not load pattern source into editor.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderPatterns = (patterns) => {
|
const renderPatterns = (patterns) => {
|
||||||
patternsList.innerHTML = '';
|
patternsList.innerHTML = '';
|
||||||
const entries = Object.entries(patterns || {});
|
const entries = Object.entries(patterns || {});
|
||||||
@@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
details.style.color = '#aaa';
|
details.style.color = '#aaa';
|
||||||
details.style.fontSize = '0.85em';
|
details.style.fontSize = '0.85em';
|
||||||
|
|
||||||
|
const sendBtn = document.createElement('button');
|
||||||
|
sendBtn.className = 'btn btn-primary btn-small';
|
||||||
|
sendBtn.textContent = 'Send';
|
||||||
|
sendBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await sendPatternToDevices(patternName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send pattern failed:', error);
|
||||||
|
alert(error.message || 'Failed to send pattern.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', async () => {
|
||||||
|
if (patternEditorModal) {
|
||||||
|
patternEditorModal.classList.add('active');
|
||||||
|
}
|
||||||
|
await loadPatternIntoEditor(patternName, data || {});
|
||||||
|
});
|
||||||
|
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(details);
|
row.appendChild(details);
|
||||||
|
row.appendChild(editBtn);
|
||||||
|
row.appendChild(sendBtn);
|
||||||
patternsList.appendChild(row);
|
patternsList.appendChild(row);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPatterns = async () => {
|
async function loadPatterns() {
|
||||||
patternsList.innerHTML = '';
|
patternsList.innerHTML = '';
|
||||||
const loading = document.createElement('p');
|
const loading = document.createElement('p');
|
||||||
loading.className = 'muted-text';
|
loading.className = 'muted-text';
|
||||||
@@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
errorMessage.textContent = 'Failed to load patterns.';
|
errorMessage.textContent = 'Failed to load patterns.';
|
||||||
patternsList.appendChild(errorMessage);
|
patternsList.appendChild(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
patternsModal.classList.add('active');
|
patternsModal.classList.add('active');
|
||||||
@@ -74,6 +388,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
patternsButton.addEventListener('click', openModal);
|
patternsButton.addEventListener('click', openModal);
|
||||||
|
if (patternAddButton) {
|
||||||
|
patternAddButton.addEventListener('click', () => {
|
||||||
|
resetCreateForm();
|
||||||
|
if (patternEditorModal) {
|
||||||
|
patternEditorModal.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (patternEditorCloseButton) {
|
||||||
|
patternEditorCloseButton.addEventListener('click', () => {
|
||||||
|
if (patternEditorModal) {
|
||||||
|
patternEditorModal.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (patternsCloseButton) {
|
if (patternsCloseButton) {
|
||||||
patternsCloseButton.addEventListener('click', closeModal);
|
patternsCloseButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,14 +548,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
presetPatternInput.style.cursor = '';
|
presetPatternInput.style.cursor = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update labels and visibility based on pattern
|
|
||||||
updatePresetNLabels(patternName);
|
|
||||||
|
|
||||||
// Get pattern config to map descriptive names back to n keys
|
// Get pattern config to map descriptive names back to n keys
|
||||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
||||||
const nToLabel = {};
|
const nToLabel = {};
|
||||||
if (patternConfig && typeof patternConfig === 'object') {
|
if (patternConfig && typeof patternConfig === 'object') {
|
||||||
// Now n keys are keys, labels are values
|
|
||||||
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
Object.entries(patternConfig).forEach(([nKey, label]) => {
|
||||||
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
|
||||||
nToLabel[nKey] = label;
|
nToLabel[nKey] = label;
|
||||||
@@ -568,11 +564,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||||
if (inputEl) {
|
if (inputEl) {
|
||||||
// First check if preset has n key directly
|
|
||||||
if (preset[nKey] !== undefined) {
|
if (preset[nKey] !== undefined) {
|
||||||
inputEl.value = preset[nKey] || 0;
|
inputEl.value = preset[nKey] || 0;
|
||||||
} else {
|
} else {
|
||||||
// Check if preset has descriptive name (from pattern.json mapping)
|
|
||||||
const label = nToLabel[nKey];
|
const label = nToLabel[nKey];
|
||||||
if (label && preset[label] !== undefined) {
|
if (label && preset[label] !== undefined) {
|
||||||
inputEl.value = preset[label] || 0;
|
inputEl.value = preset[label] || 0;
|
||||||
@@ -582,6 +576,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||||||
|
updatePresetNLabels(patternName);
|
||||||
updatePresetEditorTabActionsVisibility();
|
updatePresetEditorTabActionsVisibility();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -774,44 +771,65 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updatePresetNLabels = (patternName) => {
|
const updatePresetNLabels = (patternName) => {
|
||||||
|
const rawPatternName = String(patternName || '').trim();
|
||||||
|
const normalizedPatternName = rawPatternName.endsWith('.py')
|
||||||
|
? rawPatternName.slice(0, -3)
|
||||||
|
: rawPatternName;
|
||||||
|
let patternConfig =
|
||||||
|
(cachedPatterns && cachedPatterns[rawPatternName]) ||
|
||||||
|
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
|
||||||
|
null;
|
||||||
|
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
|
||||||
|
const lower = normalizedPatternName.toLowerCase();
|
||||||
|
const matchedKey = Object.keys(cachedPatterns).find(
|
||||||
|
(k) => String(k).toLowerCase() === lower,
|
||||||
|
);
|
||||||
|
if (matchedKey) {
|
||||||
|
patternConfig = cachedPatterns[matchedKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
|
||||||
|
patternConfig = patternConfig.data;
|
||||||
|
}
|
||||||
|
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
|
||||||
|
patternConfig = patternConfig.parameter_mappings;
|
||||||
|
}
|
||||||
const labels = {};
|
const labels = {};
|
||||||
const visibleNKeys = new Set();
|
const visibleNKeys = new Set();
|
||||||
|
|
||||||
// Initialize all labels with default n1:, n2:, etc.
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
|
||||||
labels[`n${i}`] = `n${i}:`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patternConfig = cachedPatterns && cachedPatterns[patternName];
|
|
||||||
if (patternConfig && typeof patternConfig === 'object') {
|
if (patternConfig && typeof patternConfig === 'object') {
|
||||||
// Now n values are keys and descriptive names are values
|
|
||||||
Object.entries(patternConfig).forEach(([key, label]) => {
|
Object.entries(patternConfig).forEach(([key, label]) => {
|
||||||
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
|
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
|
||||||
labels[key] = `${label}:`;
|
const text = label.trim();
|
||||||
visibleNKeys.add(key); // Mark this n key as visible
|
if (text) {
|
||||||
|
labels[key] = `${text}:`;
|
||||||
|
visibleNKeys.add(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update labels and show/hide input groups
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
const labelEl = document.getElementById(`preset-${nKey}-label`);
|
||||||
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
|
||||||
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
||||||
|
const show = visibleNKeys.has(nKey);
|
||||||
|
const inputEl = document.getElementById(`preset-${nKey}-input`);
|
||||||
|
|
||||||
if (labelEl) {
|
if (labelEl) {
|
||||||
labelEl.textContent = labels[nKey];
|
labelEl.textContent = show ? labels[nKey] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show or hide the entire group based on whether it has a mapping
|
|
||||||
if (groupEl) {
|
if (groupEl) {
|
||||||
if (visibleNKeys.has(nKey)) {
|
groupEl.style.display = show ? '' : 'none';
|
||||||
groupEl.style.display = ''; // Show
|
|
||||||
} else {
|
|
||||||
groupEl.style.display = 'none'; // Hide
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (inputEl && !show) {
|
||||||
|
inputEl.value = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid');
|
||||||
|
if (nGrid) {
|
||||||
|
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -845,6 +863,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
editButton.addEventListener('click', async () => {
|
editButton.addEventListener('click', async () => {
|
||||||
currentEditId = presetId;
|
currentEditId = presetId;
|
||||||
currentEditTabId = null;
|
currentEditTabId = null;
|
||||||
|
await loadPatterns();
|
||||||
const paletteColors = await getCurrentProfilePaletteColors();
|
const paletteColors = await getCurrentProfilePaletteColors();
|
||||||
const presetForEditor = {
|
const presetForEditor = {
|
||||||
...(preset || {}),
|
...(preset || {}),
|
||||||
|
|||||||
@@ -458,22 +458,28 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
.n-param-group {
|
.n-param-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-param-group label {
|
.n-param-group label {
|
||||||
min-width: 40px;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-input {
|
.n-input {
|
||||||
flex: 1;
|
flex: 0 0 var(--n-input-width, 5ch);
|
||||||
|
width: var(--n-input-width, 5ch);
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid #4a4a4a;
|
border: 1px solid #4a4a4a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-input:focus {
|
.n-input:focus {
|
||||||
@@ -1251,6 +1257,48 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-editor-field label {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-editor-field input[type="number"] {
|
||||||
|
width: var(--n-input-width, 5ch);
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pattern editor: numeric metadata row */
|
||||||
|
#pattern-editor-modal input[type="number"] {
|
||||||
|
width: var(--n-input-width, 5ch);
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pattern editor: human-readable n labels (text), full width */
|
||||||
|
#pattern-editor-modal .n-params-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pattern-editor-modal .pattern-n-readable-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports not selector(:has(*)) {
|
||||||
|
#pattern-editor-modal #pattern-create-n-section .n-param-group {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings modal */
|
/* Settings modal */
|
||||||
|
|||||||
@@ -240,6 +240,9 @@
|
|||||||
<div id="patterns-modal" class="modal">
|
<div id="patterns-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Patterns</h2>
|
<h2>Patterns</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||||
|
</div>
|
||||||
<div id="patterns-list" class="profiles-list"></div>
|
<div id="patterns-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||||
@@ -247,6 +250,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern Editor Modal -->
|
||||||
|
<div id="pattern-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Pattern</h2>
|
||||||
|
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
|
||||||
|
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
|
||||||
|
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
|
||||||
|
<input type="number" id="pattern-create-min-delay" min="0" value="10">
|
||||||
|
<label for="pattern-create-max-delay">Max delay (ms)</label>
|
||||||
|
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
|
||||||
|
<label for="pattern-create-max-colors">Max colours</label>
|
||||||
|
<input type="number" id="pattern-create-max-colors" min="0" value="10">
|
||||||
|
</div>
|
||||||
|
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
|
||||||
|
<h3 class="muted-text">Readable parameter names</h3>
|
||||||
|
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
|
||||||
|
<div class="n-params-grid">
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n1"></label>
|
||||||
|
<input type="text" id="pattern-create-n1" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n2"></label>
|
||||||
|
<input type="text" id="pattern-create-n2" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n3"></label>
|
||||||
|
<input type="text" id="pattern-create-n3" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n4"></label>
|
||||||
|
<input type="text" id="pattern-create-n4" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n5"></label>
|
||||||
|
<input type="text" id="pattern-create-n5" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n6"></label>
|
||||||
|
<input type="text" id="pattern-create-n6" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n7"></label>
|
||||||
|
<input type="text" id="pattern-create-n7" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="n-param-group">
|
||||||
|
<label for="pattern-create-n8"></label>
|
||||||
|
<input type="text" id="pattern-create-n8" class="n-input pattern-n-readable-input" placeholder="" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
|
||||||
|
<label for="pattern-create-file">Pattern file</label>
|
||||||
|
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
|
||||||
|
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
|
||||||
|
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 0.35rem; margin-right: auto;">
|
||||||
|
<input type="checkbox" id="pattern-create-overwrite" checked>
|
||||||
|
<span>Overwrite existing file</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Colour Palette Modal -->
|
<!-- Colour Palette Modal -->
|
||||||
<div id="color-palette-modal" class="modal">
|
<div id="color-palette-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ def _split_serial_envelope(inner_json_str, peer_hex_list):
|
|||||||
return json.dumps(env, separators=(",", ":"))
|
return json.dumps(env, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _wifi_message_for_device(msg, device_name):
|
||||||
|
"""
|
||||||
|
For Wi-Fi TCP fanout, narrow a v1 select map to a single device name.
|
||||||
|
Returns the original message when no narrowing applies.
|
||||||
|
"""
|
||||||
|
if not device_name:
|
||||||
|
return msg
|
||||||
|
try:
|
||||||
|
body = json.loads(msg)
|
||||||
|
except Exception:
|
||||||
|
return msg
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return msg
|
||||||
|
select = body.get("select")
|
||||||
|
if not isinstance(select, dict):
|
||||||
|
return msg
|
||||||
|
if device_name not in select:
|
||||||
|
return msg
|
||||||
|
body["select"] = {device_name: select[device_name]}
|
||||||
|
return json.dumps(body, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
async def deliver_preset_broadcast_then_per_device(
|
async def deliver_preset_broadcast_then_per_device(
|
||||||
sender,
|
sender,
|
||||||
chunk_messages,
|
chunk_messages,
|
||||||
@@ -129,7 +151,9 @@ async def deliver_json_messages(sender, messages, target_macs, devices_model, de
|
|||||||
if doc and doc.get("transport") == "wifi":
|
if doc and doc.get("transport") == "wifi":
|
||||||
ip = doc.get("address")
|
ip = doc.get("address")
|
||||||
if ip:
|
if ip:
|
||||||
wifi_tasks.append(send_json_line_to_ip(ip, msg))
|
name = str(doc.get("name") or "").strip()
|
||||||
|
wifi_msg = _wifi_message_for_device(msg, name)
|
||||||
|
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
|
||||||
else:
|
else:
|
||||||
espnow_hex.append(mac)
|
espnow_hex.append(mac)
|
||||||
|
|
||||||
|
|||||||
99
tests/test_pattern_ota_send.py
Normal file
99
tests/test_pattern_ota_send.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Manual test helper for pattern OTA send flow.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python tests/test_pattern_ota_send.py --base-url http://led.local --pattern blink
|
||||||
|
python tests/test_pattern_ota_send.py --base-url http://127.0.0.1:8080 --pattern blink --device-id 102030405060
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from urllib import request, error
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json(method, url, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=15) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return resp.status, json.loads(body) if body else {}
|
||||||
|
except error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body) if body else {}
|
||||||
|
except Exception:
|
||||||
|
parsed = {"raw": body}
|
||||||
|
return e.code, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Test /patterns/<name>/send OTA flow.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--base-url",
|
||||||
|
default="http://127.0.0.1",
|
||||||
|
help="Controller base URL (default: http://127.0.0.1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pattern",
|
||||||
|
required=True,
|
||||||
|
help="Pattern name (without .py), e.g. blink",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device-id",
|
||||||
|
default="",
|
||||||
|
help="Optional device id (MAC). If omitted, sends to all Wi-Fi devices.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = args.base_url.rstrip("/")
|
||||||
|
pattern = args.pattern.strip()
|
||||||
|
if not pattern:
|
||||||
|
print("Pattern name is required.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Quick visibility before send.
|
||||||
|
status, patterns = _http_json("GET", f"{base}/patterns")
|
||||||
|
print(f"GET /patterns -> {status}")
|
||||||
|
if status != 200:
|
||||||
|
print(patterns)
|
||||||
|
return 1
|
||||||
|
if pattern not in patterns:
|
||||||
|
print(f"Pattern {pattern!r} not found in /patterns list.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
status, devices = _http_json("GET", f"{base}/devices")
|
||||||
|
print(f"GET /devices -> {status}")
|
||||||
|
if status != 200:
|
||||||
|
print(devices)
|
||||||
|
return 1
|
||||||
|
wifi_ids = [
|
||||||
|
did
|
||||||
|
for did, d in (devices or {}).items()
|
||||||
|
if isinstance(d, dict) and str(d.get("transport", "")).lower() == "wifi"
|
||||||
|
]
|
||||||
|
print(f"Wi-Fi devices in registry: {len(wifi_ids)}")
|
||||||
|
if wifi_ids:
|
||||||
|
print(" - " + "\n - ".join(wifi_ids))
|
||||||
|
|
||||||
|
payload = {"device_id": args.device_id} if args.device_id else {}
|
||||||
|
status, result = _http_json(
|
||||||
|
"POST", f"{base}/patterns/{pattern}/send", payload=payload
|
||||||
|
)
|
||||||
|
print(f"POST /patterns/{pattern}/send -> {status}")
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
if status != 200:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
89
tests/udp_server.py
Normal file
89
tests/udp_server.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""UDP echo server for testing the led-driver UDP client (MicroPython ESP32).
|
||||||
|
|
||||||
|
Listens on UDP, prints each datagram (peer + payload), sends the same bytes back.
|
||||||
|
|
||||||
|
Run on the Pi (or any host on the LAN):
|
||||||
|
|
||||||
|
python3 tests/udp_server.py
|
||||||
|
python3 tests/udp_server.py -p 8766 --bind 0.0.0.0
|
||||||
|
|
||||||
|
Pair with **`led-driver/tests/udp_client.py`**: the device broadcasts a hello; this server
|
||||||
|
echoes so the client learns the controller's **unicast IP** from the reply (firmware uses that
|
||||||
|
for HTTP to the web server only; it is not stored in settings). Some Wi‑Fi APs block broadcast between clients —
|
||||||
|
prefer a wired listener.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PORT = 8766
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="UDP echo server for led-driver tests")
|
||||||
|
parser.add_argument(
|
||||||
|
"--bind",
|
||||||
|
default="0.0.0.0",
|
||||||
|
metavar="ADDR",
|
||||||
|
help="Address to bind (default: all interfaces)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_PORT,
|
||||||
|
help=f"UDP port (default: {DEFAULT_PORT})",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.bind((args.bind, args.port))
|
||||||
|
except OSError as e:
|
||||||
|
print(f"bind {args.bind!r}:{args.port} failed: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"UDP echo listening on {args.bind}:{args.port} (Ctrl+C to stop)")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data, addr = sock.recvfrom(2048)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopping.")
|
||||||
|
return 0
|
||||||
|
client_ip, client_port = addr[0], addr[1]
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
print(f"client_ip={client_ip} client_udp_port={client_port} ({len(data)} bytes)")
|
||||||
|
print(f" payload: {text!r}")
|
||||||
|
line = data.split(b"\n", 1)[0].strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
obj = json.loads(line.decode("utf-8"))
|
||||||
|
if isinstance(obj, dict) and obj.get("type") == "led":
|
||||||
|
print(
|
||||||
|
" hello: device_name=%r mac=%r v=%r"
|
||||||
|
% (obj.get("device_name"), obj.get("mac"), obj.get("v"))
|
||||||
|
)
|
||||||
|
except (UnicodeError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
sock.sendto(data, addr)
|
||||||
|
except OSError as e:
|
||||||
|
print(f" sendto failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user