Improve led-tool flashing and settings flow

Made-with: Cursor
This commit is contained in:
2026-03-10 22:50:00 +13:00
parent accf8f06a5
commit 8df1d9dd81
4 changed files with 245 additions and 279 deletions

View File

@@ -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)

View File

@@ -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."

403
cli.py
View File

@@ -8,14 +8,26 @@ to/from MicroPython devices via mpremote.
import json import json
import argparse import argparse
import subprocess
import sys import sys
from typing import Dict, Any, List import time
from typing import Dict, Any, List, Optional
import tempfile import tempfile
import os import os
from device import copy_file, DeviceConnection 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: def download_settings(device: str) -> dict:
""" """
Download settings.json from the device. Download settings.json from the device.
@@ -72,32 +84,84 @@ def upload_settings(device: str, settings: dict) -> None:
pass 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: def print_settings(settings: Dict[str, Any]) -> None:
"""Pretty print settings dictionary.""" """Pretty print settings dictionary."""
print(json.dumps(settings, indent=2)) 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: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="LED Bar Configuration CLI Tool", description="LED Bar Configuration CLI Tool",
@@ -110,11 +174,8 @@ Examples:
# Download, edit device name and brightness, then upload # Download, edit device name and brightness, then upload
%(prog)s -n "LED-Strip-1" -b 128 %(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 # 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 # Set number of LEDs, color order, and debug mode
%(prog)s -l 60 -o rgb -d 1 %(prog)s -l 60 -o rgb -d 1
@@ -127,6 +188,16 @@ Examples:
# Reset and then follow output # Reset and then follow output
%(prog)s -r -f %(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( parser.add_argument(
"--pin", "--pin",
type=int, 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( parser.add_argument(
"-b", "--brightness", "-b", "--brightness",
type=int, type=int,
metavar="0-255",
help="Brightness (0-255)" help="Brightness (0-255)"
) )
parser.add_argument( parser.add_argument(
"-l", "--leds", "-l", "--leds",
dest="leds",
type=int, type=int,
help="Number of LEDs" metavar="N",
help="Number of LEDs (num_leds in settings.json)"
) )
parser.add_argument( parser.add_argument(
"-d", "--debug", "-d", "-debug", "--debug",
dest="debug",
type=int, type=int,
choices=[0, 1], choices=[0, 1],
metavar="0|1",
help="Debug mode (0 or 1)" help="Debug mode (0 or 1)"
) )
parser.add_argument( parser.add_argument(
"-o", "--color-order", "-o", "--order",
dest="color_order",
choices=["rgb", "rbg", "grb", "gbr", "brg", "bgr"], choices=["rgb", "rbg", "grb", "gbr", "brg", "bgr"],
help="Color order (rgb, rbg, grb, gbr, brg, bgr)" help="Color order (rgb, rbg, grb, gbr, brg, bgr)"
) )
@@ -178,28 +256,16 @@ Examples:
) )
parser.add_argument( parser.add_argument(
"-c", "--colors", "--default",
help="Colors in format 'r,g,b' or 'r,g,b;r2,g2,b2' for multiple colors" metavar="PATTERN",
) help="Default/startup pattern (startup_preset in settings.json)"
# 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( parser.add_argument(
"--no-download", "--pause",
action="store_true", type=float,
help="Don't download settings first (use empty settings)" metavar="SECONDS",
help="Pause for N seconds (use in action sequence, e.g. --reset --pause 2 --follow)"
) )
parser.add_argument( parser.add_argument(
@@ -240,96 +306,123 @@ Examples:
help="Delete a file or directory on the device (recursive for directories)" 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) try:
if args.reset: args = parser.parse_args()
try: ordered_actions = _get_ordered_actions(sys.argv)
print(f"Resetting device on {args.port}...", file=sys.stderr) except ValueError as e:
conn = DeviceConnection(args.port) print(f"Error: {e}", file=sys.stderr)
conn.reset() sys.exit(1)
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) port = args.port
if args.upload: ran_reset = False
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] # Execute actions in command-line order
remote_dir = args.upload[1] if len(args.upload) > 1 else None 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): elif action_name == 'reset':
print(f"Error: Directory does not exist: {upload_dir}", file=sys.stderr) ran_reset = True
sys.exit(1) try:
if not os.path.isdir(upload_dir): print(f"Resetting device on {port}...", file=sys.stderr)
print(f"Error: Not a directory: {upload_dir}", file=sys.stderr) conn = DeviceConnection(port)
sys.exit(1) 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: elif action_name == 'upload':
if remote_dir: if len(value) > 2:
print(f"Uploading {upload_dir} to {remote_dir} on device {args.port}...", file=sys.stderr) print("Error: -u accepts at most 2 arguments (source and optional destination)", file=sys.stderr)
else: sys.exit(1)
print(f"Uploading {upload_dir} to device on {args.port}...", file=sys.stderr) upload_dir = value[0]
conn = DeviceConnection(args.port) remote_dir = value[1] if len(value) > 1 else None
files_copied, dirs_created = conn.upload_directory(upload_dir, remote_dir) if not os.path.exists(upload_dir):
print(f"Upload complete: {files_copied} files, {dirs_created} directories created.", file=sys.stderr) print(f"Error: Directory does not exist: {upload_dir}", file=sys.stderr)
except Exception as e: sys.exit(1)
print(f"Error uploading directory: {e}", file=sys.stderr) if not os.path.isdir(upload_dir):
sys.exit(1) 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) elif action_name == 'erase_all':
if args.erase_all: try:
try: print(f"Erasing all code on device {port}...", file=sys.stderr)
print(f"Erasing all code on device {args.port}...", file=sys.stderr) conn = DeviceConnection(port)
conn = DeviceConnection(args.port) items = conn.list_files('')
# List top-level items and delete everything except settings.json for name, is_dir, size in items:
items = conn.list_files('') if name == "settings.json":
for name, is_dir, size in items: continue
if name == "settings.json": conn.delete(name)
continue print("Erase complete.", file=sys.stderr)
path = name except Exception as e:
conn.delete(path) print(f"Error erasing device: {e}", file=sys.stderr)
print("Erase complete.", file=sys.stderr) sys.exit(1)
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) elif action_name == 'rm':
if args.rm: try:
try: print(f"Deleting {value} on device {port}...", file=sys.stderr)
print(f"Deleting {args.rm} on device {args.port}...", file=sys.stderr) conn = DeviceConnection(port)
conn = DeviceConnection(args.port) conn.delete(value)
conn.delete(args.rm) print(f"Successfully deleted: {value}", file=sys.stderr)
print(f"Successfully deleted: {args.rm}", file=sys.stderr) except Exception as e:
except Exception as e: print(f"Error deleting {value}: {e}", file=sys.stderr)
print(f"Error deleting {args.rm}: {e}", file=sys.stderr) sys.exit(1)
sys.exit(1)
# Handle follow option (can be combined with reset) elif action_name == 'follow':
if args.follow: if ran_reset:
try:
if args.reset:
# Small delay after reset to let device boot
import time
time.sleep(0.5) time.sleep(0.5)
print(f"Following output from {args.port}... (Press Ctrl+C to stop)", file=sys.stderr) try:
conn = DeviceConnection(args.port) print(f"Following output from {port}... (Press Ctrl+C to stop)", file=sys.stderr)
conn.follow_output() conn = DeviceConnection(port)
except KeyboardInterrupt: conn.follow_output()
print("\nStopped following.", file=sys.stderr) except KeyboardInterrupt:
sys.exit(0) print("\nStopped following.", file=sys.stderr)
except Exception as e: sys.exit(0)
print(f"Error following output: {e}", file=sys.stderr) except Exception as e:
sys.exit(1) print(f"Error following output: {e}", file=sys.stderr)
return # Don't continue with settings operations if following sys.exit(1)
return # follow blocks; when interrupted we're done
# If only reset was specified (without follow/other actions), we're done # If we ran any actions and follow wasn't last, we're done
if args.reset and not (args.upload or args.erase_all or args.rm or args.show): if ordered_actions:
return return
# Collect all edit parameters # Collect all edit parameters
@@ -356,52 +449,36 @@ Examples:
if args.preset is not None: if args.preset is not None:
edits["pattern"] = args.preset edits["pattern"] = args.preset
if args.colors is not None: if args.default is not None:
colors = parse_colors(args.colors) edits["startup_preset"] = args.default
edits["colors"] = colors
# Add n1-n8 parameters # 1. Download: get current settings from device
for i in range(1, 9): try:
attr_name = f"n{i}" print(f"Downloading settings from {args.port}...", file=sys.stderr)
value = getattr(args, attr_name, None) settings = download_settings(args.port)
if value is not None: print("Settings downloaded successfully.", file=sys.stderr)
edits[attr_name] = value 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 --show only, print and exit
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: if args.show:
print_settings(settings) print_settings(settings)
if not edits: if not edits:
return return
# Apply edits # 2. Edit: apply edits to downloaded settings
if edits: if edits:
print(f"Applying {len(edits)} edit(s)...", file=sys.stderr) print(f"Applying {len(edits)} edit(s)...", file=sys.stderr)
settings.update(edits) 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) print_settings(settings)
# Upload if edits were made and --no-upload is not specified # 3. Upload: write settings back to device when edits were made
if edits and not args.no_upload: if edits:
try: try:
print(f"\nUploading settings to {args.port}...", file=sys.stderr) print(f"\nUploading settings to {args.port}...", file=sys.stderr)
upload_settings(args.port, settings) upload_settings(args.port, settings)
@@ -412,8 +489,6 @@ Examples:
else: else:
print(f"Error uploading settings: {e}", file=sys.stderr) print(f"Error uploading settings: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
elif edits and args.no_upload:
print("\nSettings modified but not uploaded (--no-upload specified).", file=sys.stderr)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,5 +1,9 @@
import errno 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. # This table maps numeric values defined by `py/mperrno.h` to host errno code.
MP_ERRNO_TABLE = { MP_ERRNO_TABLE = {
@@ -51,5 +55,5 @@ MP_ERRNO_TABLE = {
115: errno.EINPROGRESS, 115: errno.EINPROGRESS,
125: errno.ECANCELED, 125: errno.ECANCELED,
} }
if platform.system() != "Windows": if (platform.system() if platform else None) != "Windows":
MP_ERRNO_TABLE[15] = errno.ENOTBLK MP_ERRNO_TABLE[15] = errno.ENOTBLK