- 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>
421 lines
13 KiB
Python
Executable File
421 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
LED Bar Configuration CLI Tool
|
|
|
|
Command-line interface for downloading, editing, and uploading settings.json
|
|
to/from MicroPython devices via mpremote.
|
|
"""
|
|
|
|
import json
|
|
import argparse
|
|
import sys
|
|
from typing import Dict, Any, List
|
|
|
|
import tempfile
|
|
import os
|
|
from device import copy_file, DeviceConnection
|
|
|
|
|
|
def download_settings(device: str) -> dict:
|
|
"""
|
|
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:
|
|
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:
|
|
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_colors(color_string: str) -> List[tuple]:
|
|
"""
|
|
Parse color string into list of RGB tuples.
|
|
Format: "r,g,b" or "r,g,b;r2,g2,b2" for multiple colors
|
|
"""
|
|
colors = []
|
|
for color_part in color_string.split(";"):
|
|
parts = color_part.strip().split(",")
|
|
if len(parts) == 3:
|
|
try:
|
|
r = int(parts[0].strip())
|
|
g = int(parts[1].strip())
|
|
b = int(parts[2].strip())
|
|
colors.append((r, g, b))
|
|
except ValueError:
|
|
raise ValueError(f"Invalid color format: {color_part}. Expected 'r,g,b'")
|
|
else:
|
|
raise ValueError(f"Invalid color format: {color_part}. Expected 'r,g,b'")
|
|
return colors
|
|
|
|
|
|
def print_settings(settings: Dict[str, Any]) -> None:
|
|
"""Pretty print settings dictionary."""
|
|
print(json.dumps(settings, indent=2))
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="LED Bar Configuration CLI Tool",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Download and print current settings
|
|
%(prog)s
|
|
|
|
# Download, edit device name and brightness, then upload
|
|
%(prog)s -n "LED-Strip-1" -b 128
|
|
|
|
# Set colors (format: r,g,b or r,g,b;r2,g2,b2 for multiple)
|
|
%(prog)s -c "255,0,0;0,255,0;0,0,255"
|
|
|
|
# Set multiple parameters
|
|
%(prog)s -n "MyLED" -b 200 --preset "rainbow" -n1 10 -n2 20
|
|
|
|
# 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
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-p", "--port",
|
|
default="/dev/ttyACM0",
|
|
help="Serial port/device path (default: /dev/ttyACM0)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-n", "--name",
|
|
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,
|
|
help="Brightness (0-255)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-l", "--leds",
|
|
type=int,
|
|
help="Number of LEDs"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-d", "--debug",
|
|
type=int,
|
|
choices=[0, 1],
|
|
help="Debug mode (0 or 1)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-o", "--color-order",
|
|
choices=["rgb", "rbg", "grb", "gbr", "brg", "bgr"],
|
|
help="Color order (rgb, rbg, grb, gbr, brg, bgr)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--preset",
|
|
help="Pattern preset name"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-c", "--colors",
|
|
help="Colors in format 'r,g,b' or 'r,g,b;r2,g2,b2' for multiple colors"
|
|
)
|
|
|
|
# Add n1 through n8 parameters
|
|
for i in range(1, 9):
|
|
parser.add_argument(
|
|
f"-n{i}",
|
|
type=int,
|
|
help=f"N{i} parameter"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-upload",
|
|
action="store_true",
|
|
help="Don't upload settings after editing (only download and print)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-download",
|
|
action="store_true",
|
|
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
|
|
|
|
if args.leds is not None:
|
|
edits["num_leds"] = args.leds
|
|
|
|
if args.debug is not None:
|
|
edits["debug"] = bool(args.debug)
|
|
|
|
if args.color_order is not None:
|
|
edits["color_order"] = args.color_order
|
|
|
|
if args.preset is not None:
|
|
edits["pattern"] = args.preset
|
|
|
|
if args.colors is not None:
|
|
colors = parse_colors(args.colors)
|
|
edits["colors"] = colors
|
|
|
|
# Add n1-n8 parameters
|
|
for i in range(1, 9):
|
|
attr_name = f"n{i}"
|
|
value = getattr(args, attr_name, None)
|
|
if value is not None:
|
|
edits[attr_name] = value
|
|
|
|
# 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 Exception as e:
|
|
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)
|
|
settings.update(edits)
|
|
# Handle colors specially - convert list of tuples to format expected by device
|
|
if "colors" in edits:
|
|
# Store colors as list of lists for JSON serialization
|
|
settings["colors"] = [list(c) for c in edits["colors"]]
|
|
|
|
# Print settings
|
|
print_settings(settings)
|
|
|
|
# Upload if edits were made and --no-upload is not specified
|
|
if edits and not args.no_upload:
|
|
try:
|
|
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 Exception as e:
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|