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

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)