diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..1fa45bb --- /dev/null +++ b/cli.py @@ -0,0 +1,286 @@ +#!/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 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()}") + + +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_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 + """ + ) + + 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( + "-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)" + ) + + args = parser.parse_args() + + # Collect all edit parameters + edits: Dict[str, Any] = {} + + if args.name is not None: + edits["name"] = args.name + + 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) + if args.no_download: + 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) + sys.exit(1) + + # 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 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) + 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()