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 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__":

View File

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