4 Commits

Author SHA1 Message Date
pi
75ddd559c9 chore(db,led-tool): sync device/zone data and led-tool submodule
Made-with: Cursor
2026-04-11 15:20:26 +12:00
pi
5a1067263a chore: add pattern samples, http driver helpers, OTA/UDP test tools
- patterns/: sample dynamic pattern modules for OTA
- esp32/msg.json: example bridge message shape
- models/http_driver.py, wifi_peer.py: Wi-Fi driver HTTP poll helpers
- tests: pattern OTA send script and UDP discovery echo server
- Submodule led-driver: http_poll and test utilities

Made-with: Cursor
2026-04-11 15:19:15 +12:00
pi
e67de6215a feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers
- Device controller extensions; driver_delivery chunk handling
- main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination
- Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns

Made-with: Cursor
2026-04-11 15:10:23 +12:00
pi
7179b6531e feat(controller): udp hello discovery and remove tcp registration
Made-with: Cursor
2026-04-06 21:28:13 +12:00
24 changed files with 1860 additions and 76 deletions

View File

@@ -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": []}}

View File

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

6
patterns/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -14,6 +14,7 @@ from models.tcp_clients import (
from util.espnow_message import build_message
import asyncio
import json
import os
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
_IDENTIFY_PRESET_KEY = "__identify"
@@ -72,6 +73,37 @@ def _device_json_with_live_status(dev_dict):
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):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
@@ -259,3 +291,56 @@ async def identify_device(request, id):
return json.dumps({"message": "Identify sent"}), 200, {
"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",
}

View File

@@ -1,19 +1,67 @@
from microdot import Microdot
from models.pattern import Pattern
from models.device import Device
from models.tcp_clients import send_json_line_to_ip
import json
import re
import sys
import os
controller = Microdot()
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():
"""Load pattern definitions from pattern.json file."""
try:
# Try different paths for local development vs MicroPython
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
root = _project_root()
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:
try:
with open(path, 'r') as f:
with open(path, "r") as f:
return json.load(f)
except OSError:
continue
@@ -22,16 +70,301 @@ def load_pattern_definitions():
print(f"Error loading pattern.json: {e}")
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')
async def get_pattern_definitions(request):
"""Get pattern definitions from pattern.json."""
definitions = load_pattern_definitions()
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
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('')
async def list_patterns(request):
"""List all patterns."""
return json.dumps(patterns), 200, {'Content-Type': 'application/json'}
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')

View File

@@ -2,6 +2,7 @@ import asyncio
import errno
import json
import os
import signal
import socket
import threading
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
# fail drain() within this interval (keepalive alone is often slow or ineffective).
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.
_TCP_PEER_GONE = (
@@ -108,7 +110,7 @@ async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
return
def _register_tcp_device_sync(
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
@@ -119,13 +121,74 @@ def _register_tcp_device_sync(
)
if did:
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:
print(f"TCP device registry failed: {e}")
print(f"UDP device registry failed: {e}")
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):
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
peer = writer.get_extra_info("peername")
@@ -173,13 +236,6 @@ async def _handle_tcp_client(reader, writer):
pass
continue
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)
payload = json.dumps(parsed) if parsed else "{}"
if sender:
@@ -229,15 +285,21 @@ async def _send_bridge_wifi_channel(settings, sender):
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):
print("TCP server disabled (tcp_enabled=false)")
return
port = int(settings.get("tcp_port", 8765))
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
print(f"TCP server listening on 0.0.0.0:{port}")
async with server:
await server.serve_forever()
if tcp_holder is not None:
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):
@@ -349,24 +411,60 @@ async def main(port=80):
Device()
await _send_bridge_wifi_channel(settings, sender)
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
tcp_holder = {}
udp_holder = {"closing": False}
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:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings),
)
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"
try:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _graceful_shutdown)
shutdown_handlers_registered = True
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
try:
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__":
import os

125
src/models/http_driver.py Normal file
View 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
View 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

View File

@@ -3,11 +3,301 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsModal = document.getElementById('patterns-modal');
const patternsCloseButton = document.getElementById('patterns-close-btn');
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) {
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) => {
patternsList.innerHTML = '';
const entries = Object.entries(patterns || {});
@@ -32,13 +322,37 @@ document.addEventListener('DOMContentLoaded', () => {
details.style.color = '#aaa';
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(details);
row.appendChild(editBtn);
row.appendChild(sendBtn);
patternsList.appendChild(row);
});
};
const loadPatterns = async () => {
async function loadPatterns() {
patternsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
@@ -62,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => {
errorMessage.textContent = 'Failed to load patterns.';
patternsList.appendChild(errorMessage);
}
};
}
const openModal = () => {
patternsModal.classList.add('active');
@@ -74,6 +388,21 @@ document.addEventListener('DOMContentLoaded', () => {
};
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) {
patternsCloseButton.addEventListener('click', closeModal);
}

View File

@@ -548,14 +548,10 @@ document.addEventListener('DOMContentLoaded', () => {
presetPatternInput.style.cursor = '';
}
// Update labels and visibility based on pattern
updatePresetNLabels(patternName);
// Get pattern config to map descriptive names back to n keys
const patternConfig = cachedPatterns && cachedPatterns[patternName];
const nToLabel = {};
if (patternConfig && typeof patternConfig === 'object') {
// Now n keys are keys, labels are values
Object.entries(patternConfig).forEach(([nKey, label]) => {
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
nToLabel[nKey] = label;
@@ -568,11 +564,9 @@ document.addEventListener('DOMContentLoaded', () => {
const nKey = `n${i}`;
const inputEl = document.getElementById(`preset-${nKey}-input`);
if (inputEl) {
// First check if preset has n key directly
if (preset[nKey] !== undefined) {
inputEl.value = preset[nKey] || 0;
} else {
// Check if preset has descriptive name (from pattern.json mapping)
const label = nToLabel[nKey];
if (label && preset[label] !== undefined) {
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();
};
@@ -774,44 +771,65 @@ document.addEventListener('DOMContentLoaded', () => {
};
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 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') {
// Now n values are keys and descriptive names are values
Object.entries(patternConfig).forEach(([key, label]) => {
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
labels[key] = `${label}:`;
visibleNKeys.add(key); // Mark this n key as visible
const text = label.trim();
if (text) {
labels[key] = `${text}:`;
visibleNKeys.add(key);
}
}
});
}
// Update labels and show/hide input groups
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
const labelEl = document.getElementById(`preset-${nKey}-label`);
const inputEl = document.getElementById(`preset-${nKey}-input`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
const show = visibleNKeys.has(nKey);
const inputEl = document.getElementById(`preset-${nKey}-input`);
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 (visibleNKeys.has(nKey)) {
groupEl.style.display = ''; // Show
} else {
groupEl.style.display = 'none'; // Hide
}
groupEl.style.display = show ? '' : 'none';
}
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 () => {
currentEditId = presetId;
currentEditTabId = null;
await loadPatterns();
const paletteColors = await getCurrentProfilePaletteColors();
const presetForEditor = {
...(preset || {}),

View File

@@ -458,22 +458,28 @@ body.preset-ui-run .edit-mode-only {
.n-param-group {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
justify-content: space-between;
}
.n-param-group label {
min-width: 40px;
flex: 1;
min-width: 0;
font-weight: 500;
}
.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;
background-color: #3a3a3a;
color: white;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
text-align: right;
}
.n-input:focus {
@@ -1251,6 +1257,48 @@ body.preset-ui-run .edit-mode-only {
flex: 1;
display: flex;
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 */

View File

@@ -240,6 +240,9 @@
<div id="patterns-modal" class="modal">
<div class="modal-content">
<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 class="modal-actions">
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
@@ -247,6 +250,78 @@
</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 -->
<div id="color-palette-modal" class="modal">
<div class="modal-content">

View File

@@ -18,6 +18,28 @@ def _split_serial_envelope(inner_json_str, peer_hex_list):
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(
sender,
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":
ip = doc.get("address")
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:
espnow_hex.append(mac)

View 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
View 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 WiFi 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())