Add LED tool: device, CLI, web UI, build scripts, and tests

- device.py: device communication/handling
- cli.py: CLI interface updates
- web.py: web interface
- build.py, build.sh, install.sh: build and install scripts
- Pipfile: Python dependencies
- lib/mpremote: mpremote library
- test_*.py: import and LED tests
- Updated .gitignore and README

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-01 16:00:04 +13:00
parent 4a3a384181
commit accf8f06a5
17 changed files with 2821 additions and 214 deletions

191
.gitignore vendored
View File

@@ -1,176 +1,37 @@
# ---> Python
# Byte-compiled / optimized / DLL files
# Build files
build/
sdkconfig
sdkconfig.old
# Binary files
*.bin
# Python
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.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/
venv/
ENV/
env.bak/
venv.bak/
*.egg-info/
dist/
build/
# Spyder project settings
.spyderproject
.spyproject
# PyInstaller
*.spec
*.pyz
# Rope project settings
.ropeproject
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 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
# Pipenv
Pipfile.lock

20
Pipfile Normal file
View File

@@ -0,0 +1,20 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
flask = "*"
pyserial = "*"
[dev-packages]
pyinstaller = "*"
[requires]
python_version = "3"
[scripts]
web = "python web.py"
cli = "python cli.py"
build = "pyinstaller --clean led-cli.spec"
install = "pipenv install"

View File

@@ -1,2 +1,8 @@
# led-tool
- `-s, --show`: Display current settings from device
- `--no-download`: Don't download settings first (use empty settings)
**Default behavior:** Downloads settings and prints them. If any edit flags are provided, settings are modified and uploaded automatically (unless `--no-upload` is specified).
## Device Connection
The tools use an integrated mpremote transport to communicate with MicroPython devices. Make sure your device is connected and accessible via the specified serial port.## LicenseSee LICENSE file for details.

76
build.py Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Build script for creating binary executables from the CLI and web tools.
"""
import subprocess
import sys
import os
def build_binary(script_name, output_name=None):
"""Build a binary from a Python script using PyInstaller."""
if output_name is None:
output_name = script_name.replace('.py', '')
print(f"Building {script_name} -> {output_name}...")
cmd = [
'pyinstaller',
'--onefile', # Create a single executable file
'--name', output_name,
'--clean', # Clean PyInstaller cache
script_name
]
# Add hidden imports that might be needed
hidden_imports = [
'mpremote.transport_serial',
'mpremote.transport',
'mpremote.console',
'mpremote.mp_errno',
'serial',
'serial.tools.list_ports',
]
for imp in hidden_imports:
cmd.extend(['--hidden-import', imp])
# Include the lib directory
cmd.extend(['--add-data', 'lib:lib'])
result = subprocess.run(cmd, cwd=os.path.dirname(os.path.abspath(__file__)))
if result.returncode == 0:
print(f"✓ Successfully built {output_name}")
print(f" Binary location: dist/{output_name}")
else:
print(f"✗ Failed to build {output_name}")
return False
return True
if __name__ == '__main__':
print("Building binaries for led-tool...")
print("=" * 60)
success = True
# Build CLI binary
if build_binary('cli.py', 'led-cli'):
print()
else:
success = False
# Optionally build web binary (commented out as it's less common)
# if build_binary('web.py', 'led-tool-web'):
# print()
# else:
# success = False
if success:
print("=" * 60)
print("Build complete! Binaries are in the 'dist' directory.")
else:
print("=" * 60)
print("Build failed. Check errors above.")
sys.exit(1)

37
build.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Build script for creating binaries
set -e
echo "Building led-tool binaries..."
echo "================================"
# Check if we're in a pipenv environment or use pipenv run
if command -v pipenv &> /dev/null; then
echo "Using pipenv..."
pipenv install --dev
PYINSTALLER_CMD="pipenv run pyinstaller"
else
# Check if pyinstaller is available directly
if ! command -v pyinstaller &> /dev/null; then
echo "Installing PyInstaller..."
pip install pyinstaller
fi
PYINSTALLER_CMD="pyinstaller"
fi
# Build CLI binary using the spec file
echo ""
echo "Building CLI binary..."
$PYINSTALLER_CMD --clean led-cli.spec
echo ""
echo "================================"
echo "Build complete!"
echo "Binary location: dist/led-cli"
echo ""
echo "To test: ./dist/led-cli -h"
echo ""
echo "You can distribute the binary from the dist/ directory"
echo "without requiring Python to be installed on the target system."

232
cli.py
View File

@@ -11,34 +11,32 @@ import argparse
import sys
from typing import Dict, Any, List
# Import functions from tool.py
import tempfile
import subprocess
import os
def _run_mpremote_copy(from_device: bool, device: str, temp_path: str) -> None:
"""Run mpremote copy command."""
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()}")
from device import copy_file, DeviceConnection
def download_settings(device: str) -> dict:
"""Download settings.json from the device using mpremote."""
"""
Download settings.json from the device.
Returns an empty dict if the file does not exist so that the tool
can be used on a fresh device before settings.json has been created.
"""
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)
copy_file(from_device=True, device=device, remote_path="settings.json", local_path=temp_path)
with open(temp_path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, RuntimeError) as e:
# If the file is missing on the device, treat it as empty settings
msg = str(e).lower()
if "errno 2" in msg or "enoent" in msg or "not found" in msg:
return {}
raise
finally:
if os.path.exists(temp_path):
try:
@@ -48,7 +46,7 @@ def download_settings(device: str) -> dict:
def upload_settings(device: str, settings: dict) -> None:
"""Upload settings.json to the device using mpremote and reset device."""
"""Upload settings.json to the device and reset device."""
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
temp_path = temp_file.name
@@ -56,26 +54,16 @@ def upload_settings(device: str, settings: dict) -> None:
json.dump(settings, temp_file, indent=2)
temp_file.close()
_run_mpremote_copy(from_device=False, device=device, temp_path=temp_path)
copy_file(from_device=False, device=device, remote_path="settings.json", local_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")
conn = DeviceConnection(device)
conn.connect()
conn.reset()
conn.disconnect()
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
pass
finally:
if os.path.exists(temp_path):
try:
@@ -130,6 +118,15 @@ Examples:
# Set number of LEDs, color order, and debug mode
%(prog)s -l 60 -o rgb -d 1
# Reset the device
%(prog)s -r
# Follow device output
%(prog)s -f
# Reset and then follow output
%(prog)s -r -f
"""
)
@@ -144,6 +141,12 @@ Examples:
help="Device name"
)
parser.add_argument(
"--pin",
type=int,
help="LED GPIO pin number (updates 'led_pin' in settings.json)"
)
parser.add_argument(
"-b", "--brightness",
type=int,
@@ -199,14 +202,145 @@ Examples:
help="Don't download settings first (use empty settings)"
)
parser.add_argument(
"-r", "--reset",
action="store_true",
help="Reset the device"
)
parser.add_argument(
"-f", "--follow",
action="store_true",
help="Follow device output continuously (like tail -f)"
)
parser.add_argument(
"-s", "--show",
action="store_true",
help="Display current settings from device"
)
parser.add_argument(
"-u", "--upload",
nargs='+',
metavar=("SRC", "DEST"),
help="Upload a directory recursively to the device. Usage: -u SRC [DEST] (DEST is optional, defaults to basename of SRC)"
)
parser.add_argument(
"-e",
dest="erase_all",
action="store_true",
help="Erase all code on the device (delete all files except settings.json)"
)
parser.add_argument(
"--rm",
metavar="PATH",
help="Delete a file or directory on the device (recursive for directories)"
)
args = parser.parse_args()
# Handle reset option (can be combined with follow/erase/upload)
if args.reset:
try:
print(f"Resetting device on {args.port}...", file=sys.stderr)
conn = DeviceConnection(args.port)
conn.reset()
print("Device reset.", file=sys.stderr)
except Exception as e:
print(f"Error resetting device: {e}", file=sys.stderr)
sys.exit(1)
# Handle upload option (can be combined with other operations)
if args.upload:
import os
if len(args.upload) > 2:
print("Error: -u accepts at most 2 arguments (source and optional destination)", file=sys.stderr)
sys.exit(1)
upload_dir = args.upload[0]
remote_dir = args.upload[1] if len(args.upload) > 1 else None
if not os.path.exists(upload_dir):
print(f"Error: Directory does not exist: {upload_dir}", file=sys.stderr)
sys.exit(1)
if not os.path.isdir(upload_dir):
print(f"Error: Not a directory: {upload_dir}", file=sys.stderr)
sys.exit(1)
try:
if remote_dir:
print(f"Uploading {upload_dir} to {remote_dir} on device {args.port}...", file=sys.stderr)
else:
print(f"Uploading {upload_dir} to device on {args.port}...", file=sys.stderr)
conn = DeviceConnection(args.port)
files_copied, dirs_created = conn.upload_directory(upload_dir, remote_dir)
print(f"Upload complete: {files_copied} files, {dirs_created} directories created.", file=sys.stderr)
except Exception as e:
print(f"Error uploading directory: {e}", file=sys.stderr)
sys.exit(1)
# Handle erase-all option (can be combined with other operations)
if args.erase_all:
try:
print(f"Erasing all code on device {args.port}...", file=sys.stderr)
conn = DeviceConnection(args.port)
# List top-level items and delete everything except settings.json
items = conn.list_files('')
for name, is_dir, size in items:
if name == "settings.json":
continue
path = name
conn.delete(path)
print("Erase complete.", file=sys.stderr)
except Exception as e:
print(f"Error erasing device: {e}", file=sys.stderr)
sys.exit(1)
# Handle targeted remove option (can be combined with other operations)
if args.rm:
try:
print(f"Deleting {args.rm} on device {args.port}...", file=sys.stderr)
conn = DeviceConnection(args.port)
conn.delete(args.rm)
print(f"Successfully deleted: {args.rm}", file=sys.stderr)
except Exception as e:
print(f"Error deleting {args.rm}: {e}", file=sys.stderr)
sys.exit(1)
# Handle follow option (can be combined with reset)
if args.follow:
try:
if args.reset:
# Small delay after reset to let device boot
import time
time.sleep(0.5)
print(f"Following output from {args.port}... (Press Ctrl+C to stop)", file=sys.stderr)
conn = DeviceConnection(args.port)
conn.follow_output()
except KeyboardInterrupt:
print("\nStopped following.", file=sys.stderr)
sys.exit(0)
except Exception as e:
print(f"Error following output: {e}", file=sys.stderr)
sys.exit(1)
return # Don't continue with settings operations if following
# If only reset was specified (without follow/other actions), we're done
if args.reset and not (args.upload or args.erase_all or args.rm or args.show):
return
# Collect all edit parameters
edits: Dict[str, Any] = {}
if args.name is not None:
edits["name"] = args.name
if args.pin is not None:
edits["led_pin"] = args.pin
if args.brightness is not None:
edits["brightness"] = args.brightness
@@ -233,24 +367,27 @@ Examples:
if value is not None:
edits[attr_name] = value
# Download current settings (unless --no-download is specified)
if args.no_download:
# Download current settings (unless --no-download is specified and not showing)
if args.no_download and not args.show:
settings = {}
else:
try:
print(f"Downloading settings from {args.port}...", file=sys.stderr)
settings = download_settings(args.port)
print("Settings downloaded successfully.", file=sys.stderr)
except FileNotFoundError:
print("Error: mpremote not found. Install with: pip install mpremote", file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error downloading settings: {e}", file=sys.stderr)
if "timeout" in str(e).lower() or "connection" in str(e).lower():
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
else:
print(f"Error downloading settings: {e}", file=sys.stderr)
sys.exit(1)
# If --show is set, print current settings. If there are no edits, exit afterwards.
if args.show:
print_settings(settings)
if not edits:
return
# Apply edits
if edits:
print(f"Applying {len(edits)} edit(s)...", file=sys.stderr)
@@ -269,14 +406,11 @@ Examples:
print(f"\nUploading settings to {args.port}...", file=sys.stderr)
upload_settings(args.port, settings)
print("Settings uploaded successfully. Device will reset.", file=sys.stderr)
except FileNotFoundError:
print("Error: mpremote not found. Install with: pip install mpremote", file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error uploading settings: {e}", file=sys.stderr)
if "timeout" in str(e).lower() or "connection" in str(e).lower():
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
else:
print(f"Error uploading settings: {e}", file=sys.stderr)
sys.exit(1)
elif edits and args.no_upload:
print("\nSettings modified but not uploaded (--no-upload specified).", file=sys.stderr)

246
device.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Device communication wrapper using integrated mpremote transport.
This module provides a simple interface for copying files to/from MicroPython devices
without requiring mpremote to be installed as a separate dependency.
"""
import sys
import os
import time
import serial
# Add lib directory to path - handle both normal execution and PyInstaller bundle
if getattr(sys, 'frozen', False):
# Running as a PyInstaller bundle
_this_dir = sys._MEIPASS
else:
# Running as a normal Python script
_this_dir = os.path.dirname(os.path.abspath(__file__))
lib_path = os.path.join(_this_dir, 'lib')
if lib_path not in sys.path and os.path.exists(lib_path):
sys.path.insert(0, lib_path)
from mpremote.transport_serial import SerialTransport
from mpremote.transport import TransportError
class DeviceConnection:
"""Wrapper for device communication."""
def __init__(self, device):
"""Connect to a device."""
self.device = device
self.transport = None
def connect(self):
"""Establish connection to device."""
if self.transport is None:
self.transport = SerialTransport(self.device, baudrate=115200)
self.transport.enter_raw_repl()
def disconnect(self):
"""Close connection to device."""
if self.transport:
try:
if self.transport.in_raw_repl:
self.transport.exit_raw_repl()
except Exception:
pass
try:
self.transport.close()
except Exception:
pass
self.transport = None
def copy_from_device(self, remote_path, local_path):
"""Copy a file from device to local filesystem."""
self.connect()
try:
data = self.transport.fs_readfile(remote_path)
with open(local_path, 'wb') as f:
f.write(data)
finally:
self.disconnect()
def copy_to_device(self, local_path, remote_path):
"""Copy a file from local filesystem to device."""
self.connect()
try:
with open(local_path, 'rb') as f:
data = f.read()
self.transport.fs_writefile(remote_path, data)
finally:
self.disconnect()
def upload_directory(self, local_dir, remote_dir=None):
"""
Upload a directory recursively to the device.
Args:
local_dir: Local directory path to upload
remote_dir: Remote directory path (default: root, uses basename of local_dir)
"""
import os
if not os.path.isdir(local_dir):
raise ValueError(f"{local_dir} is not a directory")
self.connect()
try:
# Determine remote directory name
if remote_dir is None:
remote_dir = os.path.basename(os.path.abspath(local_dir))
# Ensure remote directory exists
if not self.transport.fs_exists(remote_dir):
self.transport.fs_mkdir(remote_dir)
# Walk through local directory and copy files
files_copied = 0
dirs_created = 0
for root, dirs, files in os.walk(local_dir):
# Calculate relative path from local_dir
rel_path = os.path.relpath(root, local_dir)
# Build remote path
if rel_path == '.':
remote_base = remote_dir.rstrip('/') or '/'
else:
rel_path_clean = rel_path.replace(os.sep, '/')
if remote_dir.rstrip('/') == '':
remote_base = '/' + rel_path_clean
else:
remote_base = '/'.join([remote_dir.rstrip('/'), rel_path_clean])
# Create remote directory if needed
if rel_path != '.' and not self.transport.fs_exists(remote_base):
print(f"Creating directory: {remote_base}", file=sys.stderr)
self.transport.fs_mkdir(remote_base)
dirs_created += 1
# Copy files
for file in files:
local_file = os.path.join(root, file)
# Handle root directory case properly
if remote_base == '/':
remote_file = '/' + file
else:
remote_file = '/'.join([remote_base, file])
print(f"Uploading: {remote_file}", file=sys.stderr)
with open(local_file, 'rb') as f:
data = f.read()
self.transport.fs_writefile(remote_file, data)
files_copied += 1
return files_copied, dirs_created
finally:
self.disconnect()
def list_files(self, remote_path=''):
"""
List files and directories on the device.
Args:
remote_path: Path on device to list (default: root directory)
Returns:
List of (name, is_directory, size) tuples
"""
self.connect()
try:
if remote_path and not self.transport.fs_exists(remote_path):
raise ValueError(f"Path does not exist: {remote_path}")
items = self.transport.fs_listdir(remote_path)
result = []
for item in items:
is_dir = item.st_mode & 0o170000 == 0o040000
result.append((item.name, is_dir, item.st_size))
return result
finally:
self.disconnect()
def delete(self, remote_path, disconnect=True):
"""
Delete a file or directory on the device.
Args:
remote_path: Path on device to delete (file or directory)
disconnect: Whether to disconnect after deletion (default: True, set to False for recursive calls)
"""
self.connect()
try:
if not self.transport.fs_exists(remote_path):
raise ValueError(f"Path does not exist: {remote_path}")
if self.transport.fs_isdir(remote_path):
# Delete directory recursively
# First, delete all files and subdirectories
items = self.transport.fs_listdir(remote_path)
for item in items:
item_path = '/'.join([remote_path.rstrip('/'), item.name])
if item.st_mode & 0o170000 == 0o040000: # Directory
self.delete(item_path, disconnect=False) # Recursive delete, don't disconnect
else: # File
print(f"Deleting file: {item_path}", file=sys.stderr)
self.transport.fs_rmfile(item_path)
# Then delete the directory itself
print(f"Deleting directory: {remote_path}", file=sys.stderr)
self.transport.fs_rmdir(remote_path)
else:
# Delete file
print(f"Deleting file: {remote_path}", file=sys.stderr)
self.transport.fs_rmfile(remote_path)
finally:
if disconnect:
self.disconnect()
def reset(self):
"""Reset the device using serial commands (Ctrl+C, Ctrl+C, Ctrl+D)."""
# Use direct serial connection like dev.py does
try:
with serial.Serial(self.device, baudrate=115200) as ser:
ser.write(b'\x03\x03\x04') # Ctrl+C, Ctrl+C, Ctrl+D
except Exception as e:
raise TransportError(f"Failed to reset device: {e}") from e
def follow_output(self):
"""Follow device output continuously (like tail -f)."""
# Use direct serial connection like dev.py does
try:
with serial.Serial(self.device, baudrate=115200) as ser:
while True:
if ser.in_waiting > 0:
data = ser.readline().decode('utf-8', errors='replace').strip()
if data:
print(data)
else:
time.sleep(0.01) # Small delay to avoid busy-waiting
except KeyboardInterrupt:
print("\nStopped following output.", file=sys.stderr)
except Exception as e:
raise TransportError(f"Failed to follow output: {e}") from e
def copy_file(from_device, device, remote_path, local_path):
"""
Copy a file to/from device.
Args:
from_device: True to copy from device, False to copy to device
device: Device path (e.g., '/dev/ttyACM0')
remote_path: Path on device (e.g., 'settings.json')
local_path: Path on local filesystem
"""
conn = DeviceConnection(device)
try:
if from_device:
conn.copy_from_device(remote_path, local_path)
else:
conn.copy_to_device(local_path, remote_path)
except TransportError as e:
raise RuntimeError(f"Device communication error: {e}") from e

32
install.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Install script - installs dependencies and copies binary to ~/.local/bin
set -e
echo "Installing dependencies..."
pipenv install "$@"
# Build binary if it doesn't exist or if source is newer
if [ ! -f "dist/led-cli" ] || [ "cli.py" -nt "dist/led-cli" ] || [ "device.py" -nt "dist/led-cli" ]; then
echo ""
echo "Building binary..."
pipenv run pyinstaller --clean led-cli.spec
fi
# Ensure ~/.local/bin exists
mkdir -p ~/.local/bin
# Copy binary to ~/.local/bin
if [ -f "dist/led-cli" ]; then
echo ""
echo "Installing binary to ~/.local/bin/led-cli..."
cp dist/led-cli ~/.local/bin/led-cli
chmod +x ~/.local/bin/led-cli
echo "✓ Binary installed successfully!"
echo ""
echo "You can now run 'led-cli' from anywhere."
echo "Make sure ~/.local/bin is in your PATH."
else
echo "Error: Binary not found at dist/led-cli" >&2
exit 1
fi

1
lib/mpremote/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Empty __init__.py to make this a package

4
lib/mpremote/console.py Normal file
View File

@@ -0,0 +1,4 @@
# Minimal console module for mpremote compatibility
# Only exports VT_ENABLED which is used by transport_serial
VT_ENABLED = False # Disable VT for non-interactive use

55
lib/mpremote/mp_errno.py Normal file
View File

@@ -0,0 +1,55 @@
import errno
import platform
# This table maps numeric values defined by `py/mperrno.h` to host errno code.
MP_ERRNO_TABLE = {
1: errno.EPERM,
2: errno.ENOENT,
3: errno.ESRCH,
4: errno.EINTR,
5: errno.EIO,
6: errno.ENXIO,
7: errno.E2BIG,
8: errno.ENOEXEC,
9: errno.EBADF,
10: errno.ECHILD,
11: errno.EAGAIN,
12: errno.ENOMEM,
13: errno.EACCES,
14: errno.EFAULT,
16: errno.EBUSY,
17: errno.EEXIST,
18: errno.EXDEV,
19: errno.ENODEV,
20: errno.ENOTDIR,
21: errno.EISDIR,
22: errno.EINVAL,
23: errno.ENFILE,
24: errno.EMFILE,
25: errno.ENOTTY,
26: errno.ETXTBSY,
27: errno.EFBIG,
28: errno.ENOSPC,
29: errno.ESPIPE,
30: errno.EROFS,
31: errno.EMLINK,
32: errno.EPIPE,
33: errno.EDOM,
34: errno.ERANGE,
95: errno.EOPNOTSUPP,
97: errno.EAFNOSUPPORT,
98: errno.EADDRINUSE,
103: errno.ECONNABORTED,
104: errno.ECONNRESET,
105: errno.ENOBUFS,
106: errno.EISCONN,
107: errno.ENOTCONN,
110: errno.ETIMEDOUT,
111: errno.ECONNREFUSED,
113: errno.EHOSTUNREACH,
114: errno.EALREADY,
115: errno.EINPROGRESS,
125: errno.ECANCELED,
}
if platform.system() != "Windows":
MP_ERRNO_TABLE[15] = errno.ENOTBLK

210
lib/mpremote/transport.py Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
#
# This file is part of the MicroPython project, http://micropython.org/
#
# The MIT License (MIT)
#
# Copyright (c) 2023 Jim Mussared
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import ast, errno, hashlib, os, re, sys
from collections import namedtuple
from mpremote.mp_errno import MP_ERRNO_TABLE
def stdout_write_bytes(b):
b = b.replace(b"\x04", b"")
if hasattr(sys.stdout, "buffer"):
sys.stdout.buffer.write(b)
sys.stdout.buffer.flush()
else:
text = b.decode(sys.stdout.encoding, "strict")
sys.stdout.write(text)
class TransportError(Exception):
pass
class TransportExecError(TransportError):
def __init__(self, status_code, error_output):
self.status_code = status_code
self.error_output = error_output
super().__init__(error_output)
listdir_result = namedtuple("dir_result", ["name", "st_mode", "st_ino", "st_size"])
# Takes a Transport error (containing the text of an OSError traceback) and
# raises it as the corresponding OSError-derived exception.
def _convert_filesystem_error(e, info):
if "OSError" in e.error_output:
for code, estr in [
*errno.errorcode.items(),
(errno.EOPNOTSUPP, "EOPNOTSUPP"),
]:
if estr in e.error_output:
return OSError(code, info)
# Some targets don't render OSError with the name of the errno, so in these
# cases support an explicit mapping of errnos to known numeric codes.
error_lines = e.error_output.splitlines()
match = re.match(r"OSError: (\d+)$", error_lines[-1])
if match:
value = int(match.group(1), 10)
if value in MP_ERRNO_TABLE:
return OSError(MP_ERRNO_TABLE[value], info)
return e
class Transport:
def fs_listdir(self, src=""):
buf = bytearray()
def repr_consumer(b):
buf.extend(b.replace(b"\x04", b""))
cmd = "import os\nfor f in os.ilistdir(%s):\n print(repr(f), end=',')" % (
("'%s'" % src) if src else ""
)
try:
buf.extend(b"[")
self.exec(cmd, data_consumer=repr_consumer)
buf.extend(b"]")
except TransportExecError as e:
raise _convert_filesystem_error(e, src) from None
return [
listdir_result(*f) if len(f) == 4 else listdir_result(*(f + (0,)))
for f in ast.literal_eval(buf.decode())
]
def fs_stat(self, src):
try:
self.exec("import os")
return os.stat_result(self.eval("os.stat(%s)" % ("'%s'" % src)))
except TransportExecError as e:
raise _convert_filesystem_error(e, src) from None
def fs_exists(self, src):
try:
self.fs_stat(src)
return True
except OSError:
return False
def fs_isdir(self, src):
try:
mode = self.fs_stat(src).st_mode
return (mode & 0x4000) != 0
except OSError:
# Match CPython, a non-existent path is not a directory.
return False
def fs_printfile(self, src, chunk_size=256):
cmd = (
"with open('%s') as f:\n while 1:\n"
" b=f.read(%u)\n if not b:break\n print(b,end='')" % (src, chunk_size)
)
try:
self.exec(cmd, data_consumer=stdout_write_bytes)
except TransportExecError as e:
raise _convert_filesystem_error(e, src) from None
def fs_readfile(self, src, chunk_size=256, progress_callback=None):
if progress_callback:
src_size = self.fs_stat(src).st_size
contents = bytearray()
try:
self.exec("f=open('%s','rb')\nr=f.read" % src)
while True:
chunk = self.eval("r({})".format(chunk_size))
if not chunk:
break
contents.extend(chunk)
if progress_callback:
progress_callback(len(contents), src_size)
self.exec("f.close()")
except TransportExecError as e:
raise _convert_filesystem_error(e, src) from None
return contents
def fs_writefile(self, dest, data, chunk_size=256, progress_callback=None):
if progress_callback:
src_size = len(data)
written = 0
try:
self.exec("f=open('%s','wb')\nw=f.write" % dest)
while data:
chunk = data[:chunk_size]
self.exec("w(" + repr(chunk) + ")")
data = data[len(chunk) :]
if progress_callback:
written += len(chunk)
progress_callback(written, src_size)
self.exec("f.close()")
except TransportExecError as e:
raise _convert_filesystem_error(e, dest) from None
def fs_mkdir(self, path):
try:
self.exec("import os\nos.mkdir('%s')" % path)
except TransportExecError as e:
raise _convert_filesystem_error(e, path) from None
def fs_rmdir(self, path):
try:
self.exec("import os\nos.rmdir('%s')" % path)
except TransportExecError as e:
raise _convert_filesystem_error(e, path) from None
def fs_rmfile(self, path):
try:
self.exec("import os\nos.remove('%s')" % path)
except TransportExecError as e:
raise _convert_filesystem_error(e, path) from None
def fs_touchfile(self, path):
try:
self.exec("f=open('%s','a')\nf.close()" % path)
except TransportExecError as e:
raise _convert_filesystem_error(e, path) from None
def fs_hashfile(self, path, algo, chunk_size=256):
try:
self.exec("import hashlib\nh = hashlib.{algo}()".format(algo=algo))
except TransportExecError:
# hashlib (or hashlib.{algo}) not available on device. Do the hash locally.
data = self.fs_readfile(path, chunk_size=chunk_size)
return getattr(hashlib, algo)(data).digest()
try:
self.exec(
"buf = memoryview(bytearray({chunk_size}))\nwith open('{path}', 'rb') as f:\n while True:\n n = f.readinto(buf)\n if n == 0:\n break\n h.update(buf if n == {chunk_size} else buf[:n])\n".format(
chunk_size=chunk_size, path=path
)
)
return self.eval("h.digest()")
except TransportExecError as e:
raise _convert_filesystem_error(e, path) from None

File diff suppressed because it is too large Load Diff

43
test_imports.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Test script to verify imports work"""
import sys
import os
# Test 1: Check if file exists
transport_serial_path = 'lib/mpremote/transport_serial.py'
print(f"1. Checking if {transport_serial_path} exists...")
if os.path.exists(transport_serial_path):
print(f" ✓ File exists ({os.path.getsize(transport_serial_path)} bytes)")
else:
print(f" ✗ File does NOT exist!")
sys.exit(1)
# Test 2: Add lib to path and import
print("2. Testing imports...")
sys.path.insert(0, 'lib')
try:
from mpremote.transport_serial import SerialTransport
print(" ✓ transport_serial import works")
except Exception as e:
print(f" ✗ transport_serial import failed: {e}")
sys.exit(1)
# Test 3: Import device module
try:
import device
print(" ✓ device module import works")
except Exception as e:
print(f" ✗ device module import failed: {e}")
sys.exit(1)
# Test 4: Import cli module
try:
import cli
print(" ✓ cli module import works")
except Exception as e:
print(f" ✗ cli module import failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
print("\n✓ All imports successful!")

35
test_led_simple.py Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Simple test for LED count update"""
import json
import sys
import os
# Add current directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Test the edit logic
edits = {}
args_leds = 100
if args_leds is not None:
edits["num_leds"] = args_leds
print("Edits:", edits)
# Simulate --no-download
settings = {}
# Apply edits
if edits:
print(f"Applying {len(edits)} edit(s)...")
settings.update(edits)
# Print settings (this is what print_settings does)
print("\nOutput JSON:")
print(json.dumps(settings, indent=2))
# Verify
if settings.get("num_leds") == 100:
print("\n✓ SUCCESS: num_leds is correctly set to 100")
else:
print(f"\n✗ FAILED: num_leds is {settings.get('num_leds')}, expected 100")

87
test_leds.py Normal file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Test script for updating number of LEDs"""
import sys
import json
import subprocess
print("Testing CLI with -l (number of LEDs) option...")
print("=" * 60)
# Test 1: Check help output
print("\n1. Testing help output:")
result = subprocess.run(
[sys.executable, "cli.py", "-h"],
capture_output=True,
text=True,
cwd="/home/jimmy/projects/led-tool"
)
if "-l" in result.stdout or "--leds" in result.stdout:
print(" ✓ Help shows -l/--leds option")
else:
print(" ✗ Help does not show -l/--leds option")
print(f" Output: {result.stdout[:200]}")
# Test 2: Test with --no-download and --no-upload (won't connect to device)
print("\n2. Testing -l 100 with --no-download --no-upload:")
result = subprocess.run(
[sys.executable, "cli.py", "-l", "100", "--no-download", "--no-upload"],
capture_output=True,
text=True,
cwd="/home/jimmy/projects/led-tool"
)
output = result.stdout
print(f" Return code: {result.returncode}")
print(f" Output:\n{output}")
# Check if num_leds is in the output
if "num_leds" in output or '"num_leds": 100' in output:
print(" ✓ num_leds is set to 100 in output")
# Try to parse as JSON
try:
# Extract JSON from output (it should be the last part)
lines = output.strip().split('\n')
json_str = '\n'.join([l for l in lines if l.strip().startswith('{') or l.strip().startswith('"')])
if not json_str:
json_str = output.strip()
settings = json.loads(json_str)
if settings.get("num_leds") == 100:
print(" ✓ JSON is valid and num_leds = 100")
else:
print(f" ✗ num_leds is {settings.get('num_leds')}, expected 100")
except json.JSONDecodeError as e:
print(f" ⚠ Could not parse as JSON: {e}")
print(f" Raw output: {output[:500]}")
else:
print(" ✗ num_leds not found in output")
print(f" Output: {output[:500]}")
# Test 3: Test with multiple options including LEDs
print("\n3. Testing -l 60 with other options:")
result = subprocess.run(
[sys.executable, "cli.py", "-l", "60", "-b", "128", "-n", "TestLED", "--no-download", "--no-upload"],
capture_output=True,
text=True,
cwd="/home/jimmy/projects/led-tool"
)
output = result.stdout
if result.returncode == 0:
try:
settings = json.loads(output.strip())
if settings.get("num_leds") == 60:
print(" ✓ num_leds is set to 60")
if settings.get("brightness") == 128:
print(" ✓ brightness is set to 128")
if settings.get("name") == "TestLED":
print(" ✓ name is set to TestLED")
print(f" Settings: {json.dumps(settings, indent=2)}")
except json.JSONDecodeError:
print(f" ⚠ Could not parse output as JSON")
print(f" Output: {output[:500]}")
else:
print(f" ✗ Command failed with return code {result.returncode}")
print(f" Error: {result.stderr}")
print("\n" + "=" * 60)
print("Test complete!")

707
web.py Normal file
View File

@@ -0,0 +1,707 @@
#!/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 os
from pathlib import Path
from device import copy_file, DeviceConnection
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 download_settings(device: str) -> dict:
"""Download settings.json from the device."""
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
temp_path = temp_file.name
temp_file.close()
try:
copy_file(from_device=True, device=device, remote_path="settings.json", local_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 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()
copy_file(from_device=False, device=device, remote_path="settings.json", local_path=temp_path)
# Reset device (best effort)
try:
conn = DeviceConnection(device)
conn.connect()
conn.reset()
conn.disconnect()
except Exception:
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 Exception as exc: # pylint: disable=broad-except
if "timeout" in str(exc).lower() or "connection" in str(exc).lower():
flash("Connection timeout. Check device connection.", "error")
status, status_type = "Connection timeout.", "error"
else:
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 Exception as exc: # pylint: disable=broad-except
if "timeout" in str(exc).lower() or "connection" in str(exc).lower():
flash("Connection timeout. Check device connection.", "error")
status, status_type = "Connection timeout.", "error"
else:
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()