From 8df1d9dd811c76ccaacf034aff0d340659d76f2c Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 10 Mar 2026 22:50:00 +1300 Subject: [PATCH] Improve led-tool flashing and settings flow Made-with: Cursor --- build.py | 76 -------- build.sh | 37 ---- cli.py | 403 +++++++++++++++++++++++---------------- lib/mpremote/mp_errno.py | 8 +- 4 files changed, 245 insertions(+), 279 deletions(-) delete mode 100644 build.py delete mode 100644 build.sh diff --git a/build.py b/build.py deleted file mode 100644 index 72f3c33..0000000 --- a/build.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -""" -Build script for creating binary executables from the CLI and web tools. -""" - -import subprocess -import sys -import os - -def build_binary(script_name, output_name=None): - """Build a binary from a Python script using PyInstaller.""" - if output_name is None: - output_name = script_name.replace('.py', '') - - print(f"Building {script_name} -> {output_name}...") - - cmd = [ - 'pyinstaller', - '--onefile', # Create a single executable file - '--name', output_name, - '--clean', # Clean PyInstaller cache - script_name - ] - - # Add hidden imports that might be needed - hidden_imports = [ - 'mpremote.transport_serial', - 'mpremote.transport', - 'mpremote.console', - 'mpremote.mp_errno', - 'serial', - 'serial.tools.list_ports', - ] - - for imp in hidden_imports: - cmd.extend(['--hidden-import', imp]) - - # Include the lib directory - cmd.extend(['--add-data', 'lib:lib']) - - result = subprocess.run(cmd, cwd=os.path.dirname(os.path.abspath(__file__))) - - if result.returncode == 0: - print(f"✓ Successfully built {output_name}") - print(f" Binary location: dist/{output_name}") - else: - print(f"✗ Failed to build {output_name}") - return False - - return True - -if __name__ == '__main__': - print("Building binaries for led-tool...") - print("=" * 60) - - success = True - - # Build CLI binary - if build_binary('cli.py', 'led-cli'): - print() - else: - success = False - - # Optionally build web binary (commented out as it's less common) - # if build_binary('web.py', 'led-tool-web'): - # print() - # else: - # success = False - - if success: - print("=" * 60) - print("Build complete! Binaries are in the 'dist' directory.") - else: - print("=" * 60) - print("Build failed. Check errors above.") - sys.exit(1) diff --git a/build.sh b/build.sh deleted file mode 100644 index d23b05e..0000000 --- a/build.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Build script for creating binaries - -set -e - -echo "Building led-tool binaries..." -echo "================================" - -# Check if we're in a pipenv environment or use pipenv run -if command -v pipenv &> /dev/null; then - echo "Using pipenv..." - pipenv install --dev - PYINSTALLER_CMD="pipenv run pyinstaller" -else - # Check if pyinstaller is available directly - if ! command -v pyinstaller &> /dev/null; then - echo "Installing PyInstaller..." - pip install pyinstaller - fi - PYINSTALLER_CMD="pyinstaller" -fi - -# Build CLI binary using the spec file -echo "" -echo "Building CLI binary..." -$PYINSTALLER_CMD --clean led-cli.spec - -echo "" -echo "================================" -echo "Build complete!" -echo "Binary location: dist/led-cli" -echo "" -echo "To test: ./dist/led-cli -h" -echo "" -echo "You can distribute the binary from the dist/ directory" -echo "without requiring Python to be installed on the target system." - diff --git a/cli.py b/cli.py index 8d0091c..0eb3235 100755 --- a/cli.py +++ b/cli.py @@ -8,14 +8,26 @@ to/from MicroPython devices via mpremote. import json import argparse +import subprocess import sys -from typing import Dict, Any, List +import time +from typing import Dict, Any, List, Optional import tempfile import os from device import copy_file, DeviceConnection +def resolve_flash_binary(path: str) -> Optional[str]: + """Resolve binary path: full path or in bin/ directory.""" + if os.path.isabs(path): + return path if os.path.exists(path) else None + if os.path.exists(path): + return os.path.abspath(path) + bin_path = os.path.join("bin", path) + return os.path.abspath(bin_path) if os.path.exists(bin_path) else None + + def download_settings(device: str) -> dict: """ Download settings.json from the device. @@ -72,32 +84,84 @@ def upload_settings(device: str, settings: dict) -> None: 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)) +# Flags that consume the next argv as their value (for skipping during order scan) +_FLAGS_WITH_VALUE = frozenset({ + '-p', '--port', '-n', '--name', '--pin', '-b', '--brightness', + '-l', '--leds', '-d', '-debug', '--debug', '-o', '--order', + '--preset', '--default', +}) + + +def _get_ordered_actions(argv: List[str]) -> List[tuple]: + """ + Scan argv and return list of (action_name, value) in order of appearance. + Actions: flash, pause, reset, upload, erase_all, rm, follow. + """ + actions = [] + i = 1 + while i < len(argv): + arg = argv[i] + if arg == '--pause': + if i + 1 < len(argv): + try: + secs = float(argv[i + 1]) + actions.append(('pause', secs)) + except ValueError: + raise ValueError("--pause requires a number (seconds)") + i += 2 + else: + raise ValueError("--pause requires an argument") + continue + if arg == '--flash': + if i + 1 < len(argv): + actions.append(('flash', argv[i + 1])) + i += 2 + else: + raise ValueError("--flash requires an argument") + continue + if arg in ('-r', '--reset'): + actions.append(('reset', None)) + i += 1 + continue + if arg in ('-u', '--upload'): + vals = [] + i += 1 + while i < len(argv) and not argv[i].startswith('-'): + vals.append(argv[i]) + i += 1 + if len(vals) >= 2: + break + if vals: + actions.append(('upload', vals)) + continue + if arg == '-e': + actions.append(('erase_all', None)) + i += 1 + continue + if arg == '--rm': + if i + 1 < len(argv): + actions.append(('rm', argv[i + 1])) + i += 2 + else: + raise ValueError("--rm requires an argument") + continue + if arg in ('-f', '--follow'): + actions.append(('follow', None)) + i += 1 + continue + # Skip non-action flags and their values + if arg in _FLAGS_WITH_VALUE and i + 1 < len(argv): + i += 2 + else: + i += 1 + return actions + + def main() -> None: parser = argparse.ArgumentParser( description="LED Bar Configuration CLI Tool", @@ -110,11 +174,8 @@ Examples: # 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 + %(prog)s -n "MyLED" -b 200 --preset "rainbow" # Set number of LEDs, color order, and debug mode %(prog)s -l 60 -o rgb -d 1 @@ -127,6 +188,16 @@ Examples: # Reset and then follow output %(prog)s -r -f + + # Flash firmware binary (full path or filename in bin/) + %(prog)s --flash firmware.bin + %(prog)s -p /dev/ttyUSB0 --flash /path/to/firmware.bin + + # Reset, pause 2 seconds, then follow output + %(prog)s --reset --pause 2 --follow + + # Set name, num_leds, default pattern, and upload + %(prog)s --name "MyStrip" -l 60 --default rainbow """ ) @@ -144,30 +215,37 @@ Examples: parser.add_argument( "--pin", type=int, - help="LED GPIO pin number (updates 'led_pin' in settings.json)" + metavar="N", + help="LED GPIO pin number (led_pin in settings.json)" ) parser.add_argument( "-b", "--brightness", type=int, + metavar="0-255", help="Brightness (0-255)" ) parser.add_argument( "-l", "--leds", + dest="leds", type=int, - help="Number of LEDs" + metavar="N", + help="Number of LEDs (num_leds in settings.json)" ) parser.add_argument( - "-d", "--debug", + "-d", "-debug", "--debug", + dest="debug", type=int, choices=[0, 1], + metavar="0|1", help="Debug mode (0 or 1)" ) parser.add_argument( - "-o", "--color-order", + "-o", "--order", + dest="color_order", choices=["rgb", "rbg", "grb", "gbr", "brg", "bgr"], help="Color order (rgb, rbg, grb, gbr, brg, bgr)" ) @@ -178,28 +256,16 @@ Examples: ) 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)" + "--default", + metavar="PATTERN", + help="Default/startup pattern (startup_preset in settings.json)" ) parser.add_argument( - "--no-download", - action="store_true", - help="Don't download settings first (use empty settings)" + "--pause", + type=float, + metavar="SECONDS", + help="Pause for N seconds (use in action sequence, e.g. --reset --pause 2 --follow)" ) parser.add_argument( @@ -240,96 +306,123 @@ Examples: help="Delete a file or directory on the device (recursive for directories)" ) - args = parser.parse_args() + parser.add_argument( + "--flash", + metavar="BINARY", + help="Flash firmware binary to device. Path can be full path or filename in bin/" + ) - # 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) + try: + args = parser.parse_args() + ordered_actions = _get_ordered_actions(sys.argv) + except ValueError as e: + print(f"Error: {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) + port = args.port + ran_reset = False - upload_dir = args.upload[0] - remote_dir = args.upload[1] if len(args.upload) > 1 else None + # Execute actions in command-line order + for action_name, value in ordered_actions: + if action_name == 'pause': + print(f"Pausing for {value} seconds...", file=sys.stderr) + time.sleep(value) + elif action_name == 'flash': + resolved = resolve_flash_binary(value) + if not resolved: + print(f"Error: binary not found: {value} (checked bin/)", file=sys.stderr) + sys.exit(1) + try: + print(f"Erasing flash on {port}...", file=sys.stderr) + subprocess.run(["esptool", "-p", port, "erase_flash"], check=True) + print(f"Writing {resolved} to flash at 0...", file=sys.stderr) + subprocess.run(["esptool", "-p", port, "write_flash", "0", resolved], check=True) + print("Flash complete.", file=sys.stderr) + print("Waiting 2s for device to boot...", file=sys.stderr) + time.sleep(2) + except subprocess.CalledProcessError as e: + print(f"Error flashing: {e}", file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print("Error: esptool not found. Install it with: pip install esptool", file=sys.stderr) + sys.exit(1) - 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) + elif action_name == 'reset': + ran_reset = True + try: + print(f"Resetting device on {port}...", file=sys.stderr) + conn = DeviceConnection(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) - 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) + elif action_name == 'upload': + if len(value) > 2: + print("Error: -u accepts at most 2 arguments (source and optional destination)", file=sys.stderr) + sys.exit(1) + upload_dir = value[0] + remote_dir = value[1] if len(value) > 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 {port}...", file=sys.stderr) + else: + print(f"Uploading {upload_dir} to device on {port}...", file=sys.stderr) + conn = DeviceConnection(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) + elif action_name == 'erase_all': + try: + print(f"Erasing all code on device {port}...", file=sys.stderr) + conn = DeviceConnection(port) + items = conn.list_files('') + for name, is_dir, size in items: + if name == "settings.json": + continue + conn.delete(name) + 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) + elif action_name == 'rm': + try: + print(f"Deleting {value} on device {port}...", file=sys.stderr) + conn = DeviceConnection(port) + conn.delete(value) + print(f"Successfully deleted: {value}", file=sys.stderr) + except Exception as e: + print(f"Error deleting {value}: {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 + elif action_name == 'follow': + if ran_reset: 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 + try: + print(f"Following output from {port}... (Press Ctrl+C to stop)", file=sys.stderr) + conn = DeviceConnection(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 # follow blocks; when interrupted we're done - # 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): + # If we ran any actions and follow wasn't last, we're done + if ordered_actions: return # Collect all edit parameters @@ -356,52 +449,36 @@ Examples: if args.preset is not None: edits["pattern"] = args.preset - if args.colors is not None: - colors = parse_colors(args.colors) - edits["colors"] = colors + if args.default is not None: + edits["startup_preset"] = args.default - # 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 + # 1. Download: get current settings from device + 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) - # 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 --show only, print and exit if args.show: print_settings(settings) if not edits: return - # Apply edits + # 2. Edit: apply edits to downloaded settings 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: + # 3. Upload: write settings back to device when edits were made + if edits: try: print(f"\nUploading settings to {args.port}...", file=sys.stderr) upload_settings(args.port, settings) @@ -412,8 +489,6 @@ Examples: 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__": diff --git a/lib/mpremote/mp_errno.py b/lib/mpremote/mp_errno.py index e2554ef..7bdbad8 100644 --- a/lib/mpremote/mp_errno.py +++ b/lib/mpremote/mp_errno.py @@ -1,5 +1,9 @@ import errno -import platform + +try: + import platform +except Exception: # MicroPython or restricted envs + platform = None # This table maps numeric values defined by `py/mperrno.h` to host errno code. MP_ERRNO_TABLE = { @@ -51,5 +55,5 @@ MP_ERRNO_TABLE = { 115: errno.EINPROGRESS, 125: errno.ECANCELED, } -if platform.system() != "Windows": +if (platform.system() if platform else None) != "Windows": MP_ERRNO_TABLE[15] = errno.ENOTBLK