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:
232
cli.py
232
cli.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user