#!/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()