Initial commit

This commit is contained in:
2025-12-03 13:36:19 +13:00
parent 59e42c35e1
commit 45855cf453
18 changed files with 2153 additions and 168 deletions

188
.gitignore vendored
View File

@@ -1,176 +1,30 @@
# ---> Python # Build files
# Byte-compiled / optimized / DLL files build/
sdkconfig
sdkconfig.old
# Binary files
*.bin
# Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/ env/
venv/ venv/
ENV/ *.egg-info/
env.bak/ dist/
venv.bak/ build/
# Spyder project settings # IDE
.spyderproject .vscode/
.spyproject .idea/
*.swp
# Rope project settings *.swo
.ropeproject *~
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# OS
.DS_Store
Thumbs.db

22
Pipfile Normal file
View File

@@ -0,0 +1,22 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
mpremote = "*"
pyserial = "*"
esptool = "*"
watchfiles = "*"
fastapi = "*"
uvicorn = "*"
flask = "*"
[dev-packages]
[requires]
python_version = "3"
[scripts]
dev = 'watchfiles "./dev.py /dev/ttyACM0 src reset follow"'
web = "uvicorn tool:app --host 0.0.0.0 --port 8080"

View File

@@ -1,2 +1,37 @@
# led-driver # LED Driver - MicroPython
MicroPython-based LED driver application for ESP32 microcontrollers.
## Prerequisites
- MicroPython firmware installed on ESP32
- USB cable for programming
- Python 3 with pipenv
## Setup
1. Install dependencies:
```bash
pipenv install
```
2. Deploy to device:
```bash
pipenv run dev
```
## Project Structure
```
led-driver/
├── src/
│ ├── main.py # Main application code
│ ├── patterns.py # LED pattern implementations
│ ├── patterns_base.py # Base pattern class
│ ├── settings.py # Settings management
│ └── p2p.py # Peer-to-peer communication
├── test/ # Pattern tests
├── web_app.py # Web interface
├── dev.py # Development tools
└── Pipfile # Python dependencies
```

132
dev.py Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import subprocess
import serial
import sys
import glob
def upload_src(port):
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":"], cwd="src")
def upload_lib(port):
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":"])
def list_files(port):
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":"])
def reset_device(port):
with serial.Serial(port, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04')
def follow_serial(port):
with serial.Serial(port, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0:
data = ser.readline().decode('utf-8').strip()
print(data)
def clean_settings(port):
subprocess.call(["mpremote", "connect", port, "fs", "rm", ":/settings.json"])
def flash_firmware(port):
# Find MicroPython firmware binary
firmware_files = glob.glob("*.bin")
if not firmware_files:
print("Error: No .bin firmware file found in current directory")
print("Please download MicroPython firmware and place it in the project directory")
sys.exit(1)
firmware = firmware_files[0]
if len(firmware_files) > 1:
print(f"Warning: Multiple .bin files found, using: {firmware}")
print(f"Flashing MicroPython firmware: {firmware}")
print("Erasing flash...")
subprocess.call(["esptool.py", "--port", port, "erase_flash"])
print(f"Writing firmware to flash...")
subprocess.call([
"esptool.py",
"--port", port,
"--baud", "460800",
"write_flash", "0",
firmware
])
print("Flash complete!")
def main():
port = "/dev/ttyACM0"
commands = []
i = 1
# Parse arguments manually to preserve order
while i < len(sys.argv):
arg = sys.argv[i]
if arg in ["-p", "--port"]:
if i + 1 < len(sys.argv):
port = sys.argv[i + 1]
i += 2
else:
print(f"Error: {arg} requires a port argument")
sys.exit(1)
elif arg in ["-s", "--src"]:
commands.append(("src", upload_src))
i += 1
elif arg in ["-r", "--reset"]:
commands.append(("reset", reset_device))
i += 1
elif arg in ["-f", "--follow"]:
commands.append(("follow", follow_serial))
i += 1
elif arg == "--lib":
commands.append(("lib", upload_lib))
i += 1
elif arg == "--ls":
commands.append(("ls", list_files))
i += 1
elif arg == "--clean":
commands.append(("clean", clean_settings))
i += 1
elif arg == "--flash":
commands.append(("flash", flash_firmware))
i += 1
elif arg in ["-h", "--help"]:
print("LED Driver development tools")
print("\nUsage:")
print(" ./dev.py [-p PORT] [FLAGS...]")
print("\nFlags:")
print(" -p, --port PORT Serial port (default: /dev/ttyACM0)")
print(" -s, --src Upload src directory")
print(" -r, --reset Reset device")
print(" -f, --follow Follow serial output")
print(" --lib Upload lib directory")
print(" --ls List files on device")
print(" --clean Remove settings.json from device")
print(" --flash Flash MicroPython firmware")
print("\nExamples:")
print(" ./dev.py -p /dev/ttyACM0 -s -r -f")
print(" ./dev.py --flash -s -r")
sys.exit(0)
else:
print(f"Error: Unknown argument: {arg}")
print("Use -h or --help for usage information")
sys.exit(1)
# Execute commands in the order they were given
if not commands:
print("No commands specified. Use -h or --help for usage information.")
sys.exit(1)
for cmd_name, cmd_func in commands:
if cmd_name == "reset":
print("Resetting device...")
elif cmd_name == "follow":
print("Following serial output (Ctrl+C to exit)...")
elif cmd_name == "flash":
pass # flash_firmware prints its own messages
else:
print(f"{cmd_name.capitalize()}...")
cmd_func(port)
if __name__ == "__main__":
main()

34
src/main.py Normal file
View File

@@ -0,0 +1,34 @@
from settings import Settings
from machine import WDT
from espnow import ESPNow
import network
from patterns import Patterns
import json
settings = Settings()
print(settings)
patterns = Patterns(settings["led_pin"], settings["num_leds"], selected=settings["pattern"])
patterns.colors = [(8,0,0)]
patterns.select("rainbow")
wdt = WDT(timeout=10000)
wdt.feed()
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
sta_if.disconnect()
sta_if.config(channel=1)
e = ESPNow()
e.active(True)
while True:
wdt.feed()
patterns.tick()
if e.any():
host, msg = e.recv()
data = json.loads(msg)
if settings.get("name") in data.get("names", []):
settings.set_settings(data.get("settings", {}), patterns, data.get("save", False))

16
src/p2p.py Normal file
View File

@@ -0,0 +1,16 @@
import asyncio
import aioespnow
import json
async def p2p(settings, patterns):
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
e.active(True)
async for mac, msg in e:
try:
data = json.loads(msg)
except:
print(f"Failed to load espnow data {msg}")
continue
if "names" not in data or settings.get("name") in data.get("names", []):
await settings.set_settings(data.get("settings", {}), patterns, data.get("save", False))

322
src/patterns.py Normal file
View File

@@ -0,0 +1,322 @@
import utime
from patterns_base import Patterns as PatternsBase
class Patterns(PatternsBase):
def __init__(self, pin, num_leds, color1=(0,0,0), color2=(0,0,0), brightness=127, selected="off", delay=100):
super().__init__(pin, num_leds, color1, color2, brightness, selected, delay)
self.auto = True
self.step = 0
self.patterns = {
"off": self.off,
"on" : self.on,
"blink": self.blink,
"rainbow": self.rainbow,
"pulse": self.pulse,
"transition": self.transition,
"chase": self.chase,
"circle": self.circle,
}
def blink(self):
state = True # True = on, False = off
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= self.delay:
if state:
self.fill(self.apply_brightness(self.colors[0]))
else:
self.fill((0, 0, 0))
state = not state
last_update = current_time
# Yield once per tick so other logic can run
yield
def rainbow(self):
step = self.step % 256
step_amount = max(1, int(self.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not self.auto:
for i in range(self.num_leds):
rc_index = (i * 256 // self.num_leds) + step
self.n[i] = self.apply_brightness(self.wheel(rc_index & 255))
self.n.write()
# Increment step by n1 for next manual call
self.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(self.delay)) # Access delay directly
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.num_leds):
rc_index = (i * 256 // self.num_leds) + step
self.n[i] = self.apply_brightness(self.wheel(rc_index & 255))
self.n.write()
step = (step + step_amount) % 256
self.step = step
last_update = current_time
# Yield once per tick so other logic can run
yield
def pulse(self):
self.off()
# Ensure we have at least one color
if not self.colors:
self.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 each cycle so they can be changed live
attack_ms = max(0, int(self.n1)) # Attack time in ms
hold_ms = max(0, int(self.n2)) # Hold time in ms
decay_ms = max(0, int(self.n3)) # Decay time in ms
delay_ms = max(0, int(self.delay))
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 = self.colors[color_index % len(self.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.fill(self.apply_brightness(color))
elif elapsed < attack_ms + hold_ms:
# Hold: full brightness
self.fill(self.apply_brightness(base_color))
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.fill(self.apply_brightness(color))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.fill((0, 0, 0))
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not self.auto:
break
# Skip drawing this tick, start next cycle
yield
continue
# Yield once per tick
yield
def transition(self):
"""Transition between colors, blending over `delay` ms."""
if not self.colors:
self.off()
yield
return
# Only one color: just keep it on
if len(self.colors) == 1:
while True:
self.fill(self.apply_brightness(self.colors[0]))
yield
return
color_index = 0
start_time = utime.ticks_ms()
while True:
if not self.colors:
break
# Get current and next color based on live list
c1 = self.colors[color_index % len(self.colors)]
c2 = self.colors[(color_index + 1) % len(self.colors)]
duration = max(10, int(self.delay)) # At least 10ms
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# End of this transition step
if not self.auto and color_index >= 0:
# One-shot: transition from first to second color only
self.fill(self.apply_brightness(c2))
break
# Auto: move to next pair
color_index = (color_index + 1) % len(self.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.fill(self.apply_brightness(interpolated))
yield
def chase(self):
"""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)"""
if len(self.colors) < 1:
# Need at least 1 color
return
segment_length = 0 # Will be calculated in loop
position = 0 # Current position offset
step_count = 0 # Track which step we're on
last_update = utime.ticks_ms()
while True:
# Access colors, delay, and n values directly for live updates
if not self.colors:
break
# If only one color provided, use it for both colors
if len(self.colors) < 2:
color0 = self.colors[0]
color1 = self.colors[0]
else:
color0 = self.colors[0]
color1 = self.colors[1]
color0 = self.apply_brightness(color0)
color1 = self.apply_brightness(color1)
n1 = max(1, int(self.n1)) # LEDs of color 0
n2 = max(1, int(self.n2)) # LEDs of color 1
n3 = int(self.n3) # Step movement on odd steps (can be negative)
n4 = int(self.n4) # Step movement on even steps (can be negative)
segment_length = n1 + n2
transition_duration = max(10, int(self.delay))
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Clear all LEDs
self.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.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.n[i] = color0
else:
self.n[i] = color1
self.n.write()
# Move position by n3 or n4 on alternate steps
if step_count % 2 == 0:
position = position + n3
else:
position = position + n4
# Wrap position to keep it reasonable
max_pos = self.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
step_count += 1
last_update = current_time
# Yield once per tick so other logic can run
yield
def circle(self):
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
head = 0
tail = 0
# Calculate timing
head_rate = max(1, int(self.n1)) # n1 = head moves per second
tail_rate = max(1, int(self.n3)) # n3 = tail moves per second
max_length = max(1, int(self.n2)) # n2 = max length
min_length = max(0, int(self.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"
while True:
current_time = utime.ticks_ms()
# Clear all LEDs
self.n.fill((0, 0, 0))
# Calculate segment length
segment_length = (head - tail) % self.num_leds
if segment_length == 0 and head != tail:
segment_length = self.num_leds
# Draw segment from tail to head
color = self.apply_brightness(self.colors[0])
for i in range(segment_length + 1):
led_pos = (tail + i) % self.num_leds
self.n[led_pos] = color
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.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.num_leds
last_tail_move = current_time
# Check if we've reached min length
current_length = (head - tail) % self.num_leds
if current_length == 0 and head != tail:
current_length = self.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: all LEDs off for 1 step, then restart
tail = head # Reset tail to head position to start fresh
phase = "growing"
self.n.write()
# Yield once per tick so other logic can run
yield

145
src/patterns_base.py Normal file
View File

@@ -0,0 +1,145 @@
from machine import Pin
from neopixel import NeoPixel
import utime
# Short-key parameter mapping for convenience setters
param_mapping = {
"pt": "selected",
"pa": "selected",
"cl": "colors",
"br": "brightness",
"dl": "delay",
"nl": "num_leds",
"co": "color_order",
"lp": "led_pin",
"n1": "n1",
"n2": "n2",
"n3": "n3",
"n4": "n4",
"n5": "n5",
"n6": "n6",
"auto": "auto",
}
class Patterns:
def __init__(self, pin, num_leds, color1=(0,0,0), color2=(0,0,0), brightness=127, selected="off", delay=100):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
self.pattern_step = 0
self.last_update = utime.ticks_ms()
self.delay = delay
self.brightness = brightness
self.auto = False
self.patterns = {}
self.selected = selected
# Ensure colors list always starts with at least two for robust transition handling
self.colors = [color1, color2] if color1 != color2 else [color1, (255, 255, 255)] # Fallback if initial colors are same
if not self.colors: # Ensure at least one color exists
self.colors = [(0, 0, 0)]
self.transition_duration = delay * 50 # Default transition duration
self.hold_duration = delay * 10 # Default hold duration at each color
self.transition_step = 0 # Current step in the transition
self.current_color_idx = 0 # Index of the color currently being held/transitioned from
self.current_color = self.colors[self.current_color_idx] # The actual blended color
self.hold_start_time = utime.ticks_ms() # Time when the current color hold started
# New attributes for scanner patterns
self.scanner_direction = 1 # 1 for forward, -1 for backward
self.scanner_tail_length = 3 # Number of trailing pixels
self.n1 = 0
self.n2 = 0
self.n3 = 0
self.n4 = 0
self.n5 = 0
self.n6 = 0
self.generator = None
self.select(self.selected)
def tick(self):
if self.generator is None:
return
try:
next(self.generator)
except StopIteration:
self.generator = None
def select(self, pattern):
if pattern in self.patterns:
self.selected = pattern
self.generator = self.patterns[pattern]()
print(f"Selected pattern: {pattern}")
return True
# If pattern doesn't exist, default to "off"
return False
def set_param(self, key, value):
if key in param_mapping:
setattr(self, param_mapping[key], value)
return True
print(f"Invalid parameter: {key}")
return False
def update_num_leds(self, pin, num_leds):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
self.pattern_step = 0
def set_color(self, num, color):
# Changed: More robust index check
if 0 <= num < len(self.colors):
self.colors[num] = color
# If the changed color is part of the current or next transition,
# restart the transition for smoother updates
return True
elif num == len(self.colors): # Allow setting a new color at the end
self.colors.append(color)
return True
return False
def del_color(self, num):
# Changed: More robust index check and using del for lists
if 0 <= num < len(self.colors):
del self.colors[num]
return True
return False
def apply_brightness(self, color, brightness_override=None):
effective_brightness = brightness_override if brightness_override is not None else self.brightness
return tuple(int(c * effective_brightness / 255) for c in color)
def fill(self, color=None):
fill_color = color if color is not None else self.colors[0]
for i in range(self.num_leds):
self.n[i] = fill_color
self.n.write()
def off(self):
self.fill((0, 0, 0))
def on(self):
self.fill(self.apply_brightness(self.colors[0]))
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)

131
src/settings.py Normal file
View File

@@ -0,0 +1,131 @@
import json
import ubinascii
import machine
import network
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
self.color_order = self.get_color_order(self["color_order"])
def set_defaults(self):
self["led_pin"] = 10
self["num_leds"] = 50
self["pattern"] = "on"
self["delay"] = 100
self["brightness"] = 10
self["color_order"] = "rgb"
self["name"] = f"led-{ubinascii.hexlify(network.WLAN(network.AP_IF).config('mac')).decode()}"
self["ap_password"] = ""
self["id"] = 0
self["debug"] = False
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
self.set_defaults()
self.save()
def set_settings(self, data, patterns, save):
try:
print(f"Setting settings: {data}")
for key, value in data.items():
print(key, value)
if key == "colors":
buff = []
for color in value:
buff.append(tuple(int(color[i:i+2], 16) for i in self.color_order))
patterns.colors = buff
elif key == "num_leds":
patterns.update_num_leds(self["led_pin"], value)
elif key == "pattern":
if not patterns.select(value):
return "Pattern doesn't exist", 400
elif key == "delay":
delay = int(data["delay"])
patterns.delay = delay
elif key == "brightness":
brightness = int(data["brightness"])
patterns.brightness = brightness
elif key == "n1":
patterns.n1 = value
elif key == "n2":
patterns.n2 = value
elif key == "n3":
patterns.n3 = value
elif key == "n4":
patterns.n4 = value
elif key == "n5":
patterns.n5 = value
elif key == "n6":
patterns.n6 = value
elif key == "name":
self[key] = value
self.save()
machine.reset()
elif key == "color_order":
self["color_order"] = value
self.color_order = self.get_color_order(value)
pass
elif key == "id":
pass
elif key == "led_pin":
patterns.update_num_leds(value, self["num_leds"])
else:
return "Invalid key", 400
self[key] = value
#print(self)
if save:
self.save()
print(self)
return "OK", 200
except (KeyError, ValueError):
return "Bad request", 400
def get_color_order(self, color_order):
"""Convert color order string to tuple of hex string indices."""
color_orders = {
"rgb": (1, 3, 5),
"rbg": (1, 5, 3),
"grb": (3, 1, 5),
"gbr": (3, 5, 1),
"brg": (5, 1, 3),
"bgr": (5, 3, 1)
}
return color_orders.get(color_order.lower(), (1, 3, 5)) # Default to RGB
# Example usage
def main():
settings = Settings()
print(f"Number of LEDs: {settings['num_leds']}")
settings['num_leds'] = 100
print(f"Updated number of LEDs: {settings['num_leds']}")
settings.save()
# Create a new Settings object to test loading
new_settings = Settings()
print(f"Loaded number of LEDs: {new_settings['num_leds']}")
print(settings)
# Run the example
if __name__ == "__main__":
main()

30
test/patterns/blink.py Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
p.set_param("br", 64)
p.set_param("dl", 200)
p.set_param("cl", [(255, 0, 0), (0, 0, 255)])
p.select("blink")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 1500:
wdt.feed()
p.tick()
utime.sleep_ms(10)
if __name__ == "__main__":
main()

100
test/patterns/chase.py Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)
print("Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)")
p.set_param("br", 255)
p.set_param("dl", 200)
p.set_param("n1", 5)
p.set_param("n2", 5)
p.set_param("n3", 1)
p.set_param("n4", 1)
p.set_param("cl", [(255, 0, 0), (0, 255, 0)])
p.select("chase")
run_for(p, wdt, 3000)
# Test 2: Forward and backward (n3=2, n4=-1)
print("Test 2: Forward and backward (n3=2, n4=-1)")
p.set_param("n1", 3)
p.set_param("n2", 3)
p.set_param("n3", 2)
p.set_param("n4", -1)
p.set_param("dl", 150)
p.set_param("cl", [(0, 0, 255), (255, 255, 0)])
p.select("chase")
run_for(p, wdt, 3000)
# Test 3: Large segments (n1=10, n2=5)
print("Test 3: Large segments (n1=10, n2=5, n3=3, n4=3)")
p.set_param("n1", 10)
p.set_param("n2", 5)
p.set_param("n3", 3)
p.set_param("n4", 3)
p.set_param("dl", 200)
p.set_param("cl", [(255, 128, 0), (128, 0, 255)])
p.select("chase")
run_for(p, wdt, 3000)
# Test 4: Fast movement (n3=5, n4=5)
print("Test 4: Fast movement (n3=5, n4=5)")
p.set_param("n1", 4)
p.set_param("n2", 4)
p.set_param("n3", 5)
p.set_param("n4", 5)
p.set_param("dl", 100)
p.set_param("cl", [(255, 0, 255), (0, 255, 255)])
p.select("chase")
run_for(p, wdt, 2000)
# Test 5: Backward movement (n3=-2, n4=-2)
print("Test 5: Backward movement (n3=-2, n4=-2)")
p.set_param("n1", 6)
p.set_param("n2", 4)
p.set_param("n3", -2)
p.set_param("n4", -2)
p.set_param("dl", 200)
p.set_param("cl", [(255, 255, 255), (0, 0, 0)])
p.select("chase")
run_for(p, wdt, 3000)
# Test 6: Alternating forward/backward (n3=3, n4=-2)
print("Test 6: Alternating forward/backward (n3=3, n4=-2)")
p.set_param("n1", 5)
p.set_param("n2", 5)
p.set_param("n3", 3)
p.set_param("n4", -2)
p.set_param("dl", 250)
p.set_param("cl", [(255, 0, 0), (0, 255, 0)])
p.select("chase")
run_for(p, wdt, 4000)
# Cleanup
print("Test complete, turning off")
p.select("off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

94
test/patterns/circle.py Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)
print("Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)")
p.set_param("br", 255)
p.set_param("n1", 50) # Head moves 50 LEDs/second
p.set_param("n2", 100) # Max length 100 LEDs
p.set_param("n3", 200) # Tail moves 200 LEDs/second
p.set_param("n4", 0) # Min length 0 LEDs
p.set_param("cl", [(255, 0, 0)]) # Red
p.select("circle")
run_for(p, wdt, 5000)
# Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)
print("Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)")
p.set_param("n1", 20)
p.set_param("n2", 50)
p.set_param("n3", 100)
p.set_param("n4", 0)
p.set_param("cl", [(0, 255, 0)]) # Green
p.select("circle")
run_for(p, wdt, 5000)
# Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)
print("Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)")
p.set_param("n1", 100)
p.set_param("n2", 30)
p.set_param("n3", 20)
p.set_param("n4", 0)
p.set_param("cl", [(0, 0, 255)]) # Blue
p.select("circle")
run_for(p, wdt, 5000)
# Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)
print("Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)")
p.set_param("n1", 50)
p.set_param("n2", 40)
p.set_param("n3", 100)
p.set_param("n4", 10)
p.set_param("cl", [(255, 255, 0)]) # Yellow
p.select("circle")
run_for(p, wdt, 5000)
# Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)
print("Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)")
p.set_param("n1", 200)
p.set_param("n2", 20)
p.set_param("n3", 200)
p.set_param("n4", 0)
p.set_param("cl", [(255, 0, 255)]) # Magenta
p.select("circle")
run_for(p, wdt, 3000)
# Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)
print("Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)")
p.set_param("n1", 10)
p.set_param("n2", 25)
p.set_param("n3", 10)
p.set_param("n4", 0)
p.set_param("cl", [(0, 255, 255)]) # Cyan
p.select("circle")
run_for(p, wdt, 5000)
# Cleanup
print("Test complete, turning off")
p.select("off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

27
test/patterns/off.py Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
p.select("off")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 200:
wdt.feed()
p.tick()
utime.sleep_ms(10)
if __name__ == "__main__":
main()

39
test/patterns/on.py Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
p.set_param("br", 64)
p.set_param("dl", 120)
p.set_param("cl", [(255, 0, 0), (0, 0, 255)])
# ON phase
p.select("on")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 800:
wdt.feed()
p.tick()
utime.sleep_ms(10)
# OFF phase
p.select("off")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 100:
wdt.feed()
p.tick()
utime.sleep_ms(10)
if __name__ == "__main__":
main()

79
test/patterns/pulse.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Simple single-color pulse
print("Test 1: Single-color pulse (attack=500, hold=500, decay=500, delay=500)")
p.set_param("br", 255)
p.set_param("cl", [(255, 0, 0)]) # Red
p.set_param("n1", 500) # attack ms
p.set_param("n2", 500) # hold ms
p.set_param("n3", 500) # decay ms
p.set_param("dl", 500) # delay ms between pulses
p.set_param("auto", True)
p.select("pulse")
run_for(p, wdt, 5000)
# Test 2: Faster pulse
print("Test 2: Fast pulse (attack=100, hold=100, decay=100, delay=100)")
p.set_param("n1", 100)
p.set_param("n2", 100)
p.set_param("n3", 100)
p.set_param("dl", 100)
p.set_param("cl", [(0, 255, 0)]) # Green
p.select("pulse")
run_for(p, wdt, 4000)
# Test 3: Multi-color pulse cycle
print("Test 3: Multi-color pulse (red -> green -> blue)")
p.set_param("n1", 300)
p.set_param("n2", 300)
p.set_param("n3", 300)
p.set_param("dl", 200)
p.set_param("cl", [(255, 0, 0), (0, 255, 0), (0, 0, 255)])
p.set_param("auto", True)
p.select("pulse")
run_for(p, wdt, 6000)
# Test 4: One-shot pulse (auto=False)
print("Test 4: Single pulse, auto=False")
p.set_param("n1", 400)
p.set_param("n2", 0)
p.set_param("n3", 400)
p.set_param("dl", 0)
p.set_param("cl", [(255, 255, 255)])
p.set_param("auto", False)
p.select("pulse")
# Run long enough to allow one full pulse cycle
run_for(p, wdt, 1500)
# Cleanup
print("Test complete, turning off")
p.select("off")
run_for(p, wdt, 200)
if __name__ == "__main__":
main()

123
test/patterns/rainbow.py Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Basic rainbow with auto=True (continuous)
print("Test 1: Basic rainbow (auto=True, n1=1)")
p.set_param("br", 255)
p.set_param("dl", 100) # Delay affects animation speed
p.set_param("n1", 1) # Step increment of 1
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 3000)
# Test 2: Fast rainbow
print("Test 2: Fast rainbow (low delay, n1=1)")
p.set_param("dl", 50)
p.set_param("n1", 1)
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 2000)
# Test 3: Slow rainbow
print("Test 3: Slow rainbow (high delay, n1=1)")
p.set_param("dl", 500)
p.set_param("n1", 1)
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 3000)
# Test 4: Low brightness rainbow
print("Test 4: Low brightness rainbow (n1=1)")
p.set_param("br", 64)
p.set_param("dl", 100)
p.set_param("n1", 1)
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 2000)
# Test 5: Single-step rainbow (auto=False)
print("Test 5: Single-step rainbow (auto=False, n1=1)")
p.set_param("br", 255)
p.set_param("dl", 100)
p.set_param("n1", 1)
p.set_param("auto", False)
p.step = 0
for i in range(10):
p.select("rainbow")
# One tick advances the generator one frame when auto=False
p.tick()
utime.sleep_ms(100)
wdt.feed()
# Test 6: Verify step updates correctly
print("Test 6: Verify step updates (auto=False, n1=1)")
p.set_param("n1", 1)
p.set_param("auto", False)
initial_step = p.step
p.select("rainbow")
p.tick()
final_step = p.step
print(f"Step updated from {initial_step} to {final_step} (expected increment: 1)")
# Test 7: Fast step increment (n1=5)
print("Test 7: Fast rainbow (n1=5, auto=True)")
p.set_param("br", 255)
p.set_param("dl", 100)
p.set_param("n1", 5)
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 2000)
# Test 8: Very fast step increment (n1=10)
print("Test 8: Very fast rainbow (n1=10, auto=True)")
p.set_param("n1", 10)
p.set_param("auto", True)
p.select("rainbow")
run_for(p, wdt, 2000)
# Test 9: Verify n1 controls step increment (auto=False)
print("Test 9: Verify n1 step increment (auto=False, n1=5)")
p.set_param("n1", 5)
p.set_param("auto", False)
p.step = 0
initial_step = p.step
p.select("rainbow")
p.tick()
final_step = p.step
expected_step = (initial_step + 5) % 256
print(f"Step updated from {initial_step} to {final_step} (expected: {expected_step})")
if final_step == expected_step:
print("✓ n1 step increment working correctly")
else:
print(f"✗ Step increment mismatch! Expected {expected_step}, got {final_step}")
# Cleanup
print("Test complete, turning off")
p.select("off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Simple two-color transition
print("Test 1: Two-color transition (red <-> blue, delay=1000)")
p.set_param("br", 255)
p.set_param("dl", 1000) # transition duration
p.set_param("cl", [(255, 0, 0), (0, 0, 255)])
p.set_param("auto", True)
p.select("transition")
run_for(p, wdt, 6000)
# Test 2: Multi-color transition
print("Test 2: Multi-color transition (red -> green -> blue -> white)")
p.set_param("dl", 800)
p.set_param("cl", [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)])
p.set_param("auto", True)
p.select("transition")
run_for(p, wdt, 8000)
# Test 3: One-shot transition (auto=False)
print("Test 3: One-shot transition (auto=False)")
p.set_param("dl", 1000)
p.set_param("cl", [(255, 0, 0), (0, 255, 0)])
p.set_param("auto", False)
p.select("transition")
# Run long enough for a single transition step
run_for(p, wdt, 2000)
# Test 4: Single-color behavior (should just stay on)
print("Test 4: Single-color transition (should hold color)")
p.set_param("cl", [(0, 0, 255)])
p.set_param("dl", 500)
p.set_param("auto", True)
p.select("transition")
run_for(p, wdt, 3000)
# Cleanup
print("Test complete, turning off")
p.select("off")
run_for(p, wdt, 200)
if __name__ == "__main__":
main()

734
tool.py Normal file
View File

@@ -0,0 +1,734 @@
#!/usr/bin/env python3
"""
LED Bar Configuration Web App
Flask-based web UI for downloading, editing, and uploading settings.json
to/from MicroPython devices via mpremote.
"""
import json
import tempfile
import subprocess
import os
from pathlib import Path
from flask import (
Flask,
render_template_string,
request,
redirect,
url_for,
flash,
)
app = Flask(__name__)
app.secret_key = "change-me-in-production"
SETTINGS_CONFIG = [
("led_pin", "LED Pin", "number"),
("num_leds", "Number of LEDs", "number"),
("color_order", "Color Order", "choice", ["rgb", "rbg", "grb", "gbr", "brg", "bgr"]),
("name", "Device Name", "text"),
("pattern", "Pattern", "text"),
("delay", "Delay (ms)", "number"),
("brightness", "Brightness", "number"),
("n1", "N1", "number"),
("n2", "N2", "number"),
("n3", "N3", "number"),
("n4", "N4", "number"),
("n5", "N5", "number"),
("n6", "N6", "number"),
("ap_password", "AP Password", "text"),
("id", "ID", "number"),
("debug", "Debug Mode", "choice", ["True", "False"]),
]
def _run_mpremote_copy(from_device: bool, device: str, temp_path: str) -> None:
if from_device:
cmd = ["mpremote", "connect", device, "cp", ":/settings.json", temp_path]
else:
cmd = ["mpremote", "connect", device, "cp", temp_path, ":/settings.json"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
raise RuntimeError(f"mpremote error: {result.stderr.strip() or result.stdout.strip()}")
def download_settings(device: str) -> dict:
"""Download settings.json from the device using mpremote."""
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
temp_path = temp_file.name
temp_file.close()
try:
_run_mpremote_copy(from_device=True, device=device, temp_path=temp_path)
with open(temp_path, "r", encoding="utf-8") as f:
return json.load(f)
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except OSError:
pass
def upload_settings(device: str, settings: dict) -> None:
"""Upload settings.json to the device using mpremote and reset device."""
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
temp_path = temp_file.name
try:
json.dump(settings, temp_file, indent=2)
temp_file.close()
_run_mpremote_copy(from_device=False, device=device, temp_path=temp_path)
# Reset device (best effort)
try:
import serial # type: ignore
with serial.Serial(device, baudrate=115200) as ser:
ser.write(b"\x03\x03\x04")
except Exception:
reset_cmd = [
"mpremote",
"connect",
device,
"exec",
"import machine; machine.reset()",
]
try:
subprocess.run(reset_cmd, capture_output=True, text=True, timeout=5)
except subprocess.TimeoutExpired:
pass
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except OSError:
pass
def parse_settings_from_form(form) -> dict:
settings = {}
for cfg in SETTINGS_CONFIG:
key = cfg[0]
raw = (form.get(key) or "").strip()
if raw == "":
continue
if key in ["led_pin", "num_leds", "delay", "brightness", "id", "n1", "n2", "n3", "n4", "n5", "n6"]:
try:
settings[key] = int(raw)
except ValueError:
settings[key] = raw
elif key == "debug":
settings[key] = raw == "True"
else:
settings[key] = raw
return settings
TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>LED Bar Configuration</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: dark;
--bg: #0b1020;
--bg-alt: #141b2f;
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.15);
--border: #1f2937;
--text: #e5e7eb;
--muted: #9ca3af;
--danger: #f97373;
--radius-lg: 14px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1f2937 0, #020617 55%);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.shell {
width: 100%;
max-width: 960px;
background: linear-gradient(145deg, #020617 0, #020617 40%, #030712 100%);
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.9),
0 45px 80px rgba(15, 23, 42, 0.95),
0 0 80px rgba(37, 99, 235, 0.3);
overflow: hidden;
}
header {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.4), transparent 55%);
}
header h1 {
font-size: 1.15rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
header h1 span.badge {
font-size: 0.65rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.6);
background: rgba(37, 99, 235, 0.15);
color: #bfdbfe;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.chip-row {
display: flex;
gap: 0.6rem;
align-items: center;
font-size: 0.7rem;
color: var(--muted);
}
.chip {
padding: 0.15rem 0.6rem;
border-radius: 999px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.9);
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
main {
padding: 1.25rem 1.5rem 1.5rem;
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.2fr);
gap: 1rem;
}
@media (max-width: 800px) {
main {
grid-template-columns: minmax(0, 1fr);
}
}
.card {
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 55%);
border-radius: var(--radius-lg);
border: 1px solid rgba(31, 41, 55, 0.95);
padding: 1rem;
position: relative;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 0 0, rgba(59, 130, 246, 0.4), transparent 55%),
radial-gradient(circle at 100% 0, rgba(236, 72, 153, 0.28), transparent 55%);
opacity: 0.55;
mix-blend-mode: screen;
}
.card > * { position: relative; z-index: 1; }
.card-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.75rem;
}
.card-header h2 {
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #cbd5f5;
}
.card-header span.sub {
font-size: 0.7rem;
color: var(--muted);
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.6rem 0.75rem;
}
label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.2rem;
}
input, select {
width: 100%;
padding: 0.4rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(31, 41, 55, 0.95);
background: rgba(15, 23, 42, 0.92);
color: var(--text);
font-size: 0.8rem;
outline: none;
transition: border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
}
input:focus, select:focus {
border-color: rgba(59, 130, 246, 0.95);
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.65), 0 0 25px rgba(37, 99, 235, 0.6);
background: rgba(15, 23, 42, 0.98);
}
.device-row {
display: flex;
gap: 0.55rem;
margin-top: 0.2rem;
}
.device-row input {
flex: 1;
border-radius: 999px;
}
.btn {
border-radius: 999px;
border: 1px solid transparent;
padding: 0.4rem 0.9rem;
font-size: 0.78rem;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
cursor: pointer;
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: white;
box-shadow:
0 10px 25px rgba(37, 99, 235, 0.55),
0 0 0 1px rgba(15, 23, 42, 0.95);
white-space: nowrap;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, opacity 0.1s ease;
}
.btn-secondary {
background: radial-gradient(circle at top, rgba(15, 23, 42, 0.95), rgba(17, 24, 39, 0.98));
border-color: rgba(55, 65, 81, 0.9);
color: var(--text);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.85);
}
.btn-ghost {
background: transparent;
border-color: rgba(55, 65, 81, 0.8);
color: var(--muted);
box-shadow: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow:
0 20px 40px rgba(37, 99, 235, 0.75),
0 0 0 1px rgba(191, 219, 254, 0.45);
opacity: 0.97;
}
.btn:active {
transform: translateY(0);
box-shadow:
0 10px 20px rgba(15, 23, 42, 0.9),
0 0 0 1px rgba(30, 64, 175, 0.9);
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.status {
margin-top: 0.65rem;
font-size: 0.75rem;
color: var(--muted);
display: flex;
align-items: center;
gap: 0.45rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 14px rgba(34, 197, 94, 0.95);
}
.status.error .status-dot {
background: var(--danger);
box-shadow: 0 0 14px rgba(248, 113, 113, 0.95);
}
.flash-container {
position: fixed;
right: 1.4rem;
bottom: 1.4rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 320px;
z-index: 40;
}
.flash {
padding: 0.55rem 0.75rem;
border-radius: 12px;
font-size: 0.78rem;
backdrop-filter: blur(18px);
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.8), rgba(15, 23, 42, 0.96));
border: 1px solid rgba(96, 165, 250, 0.8);
color: #e5f0ff;
box-shadow:
0 22px 40px rgba(15, 23, 42, 0.95),
0 0 30px rgba(37, 99, 235, 0.7);
}
.flash.error {
background: radial-gradient(circle at top left, rgba(248, 113, 113, 0.85), rgba(15, 23, 42, 0.96));
border-color: rgba(248, 113, 113, 0.8);
}
.flash small {
display: block;
color: rgba(226, 232, 240, 0.8);
margin-top: 0.15rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(55, 65, 81, 0.9);
font-size: 0.7rem;
color: var(--muted);
margin-top: 0.3rem;
}
</style>
</head>
<body>
<div class="shell">
<header>
<div>
<h1>
<span>LED Bar Configuration</span>
<span class="badge">Web Console</span>
</h1>
<div class="chip-row">
<span class="chip">
<span style="width: 6px; height: 6px; border-radius: 999px; background: #22c55e; box-shadow: 0 0 12px rgba(34, 197, 94, 0.95);"></span>
<span>Raspberry Pi · MicroPython</span>
</span>
<span class="chip">settings.json live editor</span>
</div>
</div>
<div class="chip-row">
<span class="chip">Device: {{ device or "/dev/ttyACM0" }}</span>
</div>
</header>
<main>
<section class="card">
<div class="card-header">
<div>
<h2>Device Connection</h2>
<span class="sub">Connect to your MicroPython LED controller and sync configuration</span>
</div>
</div>
<form method="post" action="{{ url_for('handle_action') }}">
<label for="device">Serial / mpremote device</label>
<div class="device-row">
<input
id="device"
name="device"
type="text"
value="{{ device or '/dev/ttyACM0' }}"
placeholder="/dev/ttyACM0"
required
/>
<button class="btn" type="submit" name="action" value="download">
⬇ Download
</button>
<button class="btn btn-secondary" type="submit" name="action" value="upload">
⬆ Upload
</button>
</div>
<div class="status {% if status_type == 'error' %}error{% endif %}">
<span class="status-dot"></span>
<span>{{ status or "Ready" }}</span>
</div>
<div class="pill">
<span>Tip:</span>
<span>Download from device → tweak parameters → Upload and reboot.</span>
</div>
<hr style="border: none; border-top: 1px solid rgba(31, 41, 55, 0.9); margin: 0.9rem 0 0.7rem;" />
<div class="card-header">
<div>
<h2>LED Settings</h2>
<span class="sub">Edit all fields before uploading back to your controller</span>
</div>
</div>
<div class="field-grid">
{% for field in settings_config %}
{% set key, label, field_type = field[0], field[1], field[2] %}
<div>
<label for="{{ key }}">{{ label }}</label>
{% if field_type == 'choice' %}
{% set choices = field[3] %}
<select id="{{ key }}" name="{{ key }}">
<option value=""></option>
{% for choice in choices %}
{% if key == 'debug' %}
{% set selected = 'selected' if (settings.get(key) is sameas true and choice == 'True') or (settings.get(key) is sameas false and choice == 'False') else '' %}
{% else %}
{% set selected = 'selected' if settings.get(key) == choice else '' %}
{% endif %}
<option value="{{ choice }}" {{ selected }}>{{ choice }}</option>
{% endfor %}
</select>
{% else %}
<input
id="{{ key }}"
name="{{ key }}"
type="text"
value="{{ settings.get(key, '') }}"
/>
{% endif %}
</div>
{% endfor %}
</div>
<div class="btn-row">
<button class="btn btn-secondary" type="submit" name="action" value="clear">
Reset form
</button>
<button class="btn btn-ghost" type="submit" name="action" value="from_json">
Paste JSON…
</button>
</div>
</form>
</section>
<section class="card">
<div class="card-header">
<div>
<h2>Raw JSON</h2>
<span class="sub">For advanced editing, paste or copy the full settings.json</span>
</div>
</div>
<form method="post" action="{{ url_for('handle_action') }}">
<input type="hidden" name="device" value="{{ device or '/dev/ttyACM0' }}" />
<label for="raw_json">settings.json</label>
<textarea
id="raw_json"
name="raw_json"
rows="16"
style="
width: 100%;
resize: vertical;
padding: 0.65rem 0.75rem;
border-radius: 12px;
border: 1px solid rgba(31, 41, 55, 0.95);
background: rgba(15, 23, 42, 0.96);
color: var(--text);
font-size: 0.78rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
outline: none;
"
>{{ raw_json }}</textarea>
<div class="btn-row" style="margin-top: 0.75rem;">
<button class="btn btn-secondary" type="submit" name="action" value="to_form">
Use JSON for form
</button>
<button class="btn btn-ghost" type="submit" name="action" value="pretty">
Pretty-print
</button>
</div>
</form>
</section>
</main>
</div>
<div class="flash-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {% if category == 'error' %}error{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
</body>
</html>
"""
@app.route("/", methods=["GET"])
def index():
return render_template_string(
TEMPLATE,
device="/dev/ttyACM0",
settings={},
settings_config=SETTINGS_CONFIG,
status="Ready",
status_type="ok",
raw_json="{}",
)
@app.route("/", methods=["POST"])
def handle_action():
action = request.form.get("action") or ""
device = (request.form.get("device") or "/dev/ttyACM0").strip()
raw_json = (request.form.get("raw_json") or "").strip()
settings = {}
status = "Ready"
status_type = "ok"
if action == "download":
if not device:
flash("Please specify a device.", "error")
status, status_type = "Missing device.", "error"
else:
try:
settings = download_settings(device)
raw_json = json.dumps(settings, indent=2)
flash(f"Settings downloaded from {device}.", "success")
status = f"Settings downloaded from {device}"
except subprocess.TimeoutExpired:
flash("Connection timeout. Check device connection.", "error")
status, status_type = "Connection timeout.", "error"
except FileNotFoundError:
flash("mpremote not found. Install with: pip install mpremote", "error")
status, status_type = "mpremote not found.", "error"
except Exception as exc: # pylint: disable=broad-except
flash(f"Failed to download settings: {exc}", "error")
status, status_type = "Download failed.", "error"
elif action == "upload":
if not device:
flash("Please specify a device.", "error")
status, status_type = "Missing device.", "error"
else:
# Take current form fields as source of truth, falling back to JSON if present
if raw_json:
try:
settings = json.loads(raw_json)
except json.JSONDecodeError:
flash("Raw JSON is invalid; using form values instead.", "error")
settings = {}
form_settings = parse_settings_from_form(request.form)
settings.update(form_settings)
if not settings:
flash("No settings to upload. Download or provide settings first.", "error")
status, status_type = "No settings to upload.", "error"
else:
try:
upload_settings(device, settings)
raw_json = json.dumps(settings, indent=2)
flash(f"Settings uploaded and device reset on {device}.", "success")
status = f"Settings uploaded and device reset on {device}"
except subprocess.TimeoutExpired:
flash("Connection timeout. Check device connection.", "error")
status, status_type = "Connection timeout.", "error"
except FileNotFoundError:
flash("mpremote not found. Install with: pip install mpremote", "error")
status, status_type = "mpremote not found.", "error"
except Exception as exc: # pylint: disable=broad-except
flash(f"Failed to upload settings: {exc}", "error")
status, status_type = "Upload failed.", "error"
elif action == "from_json":
# No-op here, JSON is just edited in the side panel
form_settings = parse_settings_from_form(request.form)
settings.update(form_settings)
if raw_json:
try:
settings.update(json.loads(raw_json))
flash("JSON merged into form values.", "success")
status = "JSON merged into form."
except json.JSONDecodeError:
flash("Invalid JSON; keeping previous form values.", "error")
status, status_type = "JSON parse error.", "error"
elif action == "to_form":
if raw_json:
try:
settings = json.loads(raw_json)
flash("Form fields updated from JSON.", "success")
status = "Form fields updated from JSON."
except json.JSONDecodeError:
flash("Invalid JSON; could not update form fields.", "error")
status, status_type = "JSON parse error.", "error"
elif action == "pretty":
if raw_json:
try:
parsed = json.loads(raw_json)
raw_json = json.dumps(parsed, indent=2)
settings = parsed if isinstance(parsed, dict) else {}
flash("JSON pretty-printed.", "success")
status = "JSON pretty-printed."
except json.JSONDecodeError:
flash("Invalid JSON; cannot pretty-print.", "error")
status, status_type = "JSON parse error.", "error"
elif action == "clear":
settings = {}
raw_json = "{}"
flash("Form cleared.", "success")
status = "Form cleared."
else:
# Unknown / initial action: just reflect form values back
settings = parse_settings_from_form(request.form)
if raw_json and not settings:
try:
settings = json.loads(raw_json)
except json.JSONDecodeError:
pass
return render_template_string(
TEMPLATE,
device=device,
settings=settings,
settings_config=SETTINGS_CONFIG,
status=status,
status_type=status_type,
raw_json=raw_json or json.dumps(settings or {}, indent=2),
)
def main() -> None:
# Bind to all interfaces so you can reach it from your LAN:
# python web_app.py
# Then open: http://<pi-ip>:5000/
app.run(host="0.0.0.0", port=5000, debug=False)
if __name__ == "__main__":
main()