Compare commits

8 Commits

Author SHA1 Message Date
580fd11aca fix(cli): skip redundant settings uploads when unchanged
Only upload settings when edited values differ from current device settings to avoid unnecessary resets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:48:54 +12:00
pi
d6331a105c feat(cli): add --reset-device-name and WDT feed during uploads
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:29 +12:00
2f3db9272b feat(cli): extend upload flags for src, patterns, lib, and --all
Support --patterns/--paterns, --all, --erase, and src upload excluding patterns/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:54:13 +12:00
713cd6e9a1 feat(cli): add ls flag and default lib upload
Made-with: Cursor
2026-04-15 00:46:19 +12:00
9e72c62481 fix(led-tool): build cli without spec file dependency 2026-04-14 23:13:21 +12:00
pi
eee9327e15 docs: fix readme layout and cli flags
Made-with: Cursor
2026-04-12 00:13:49 +12:00
pi
5f7acf38f0 fix(cli): remove --server-ip flag and settings edit path
Made-with: Cursor
2026-04-11 15:20:22 +12:00
pi
e86312437c feat(cli): create presets on device; flags for led-driver transport
Made-with: Cursor
2026-04-05 21:12:58 +12:00
5 changed files with 395 additions and 39 deletions

View File

@@ -16,5 +16,5 @@ python_version = "3"
[scripts]
web = "python web.py"
cli = "python cli.py"
build = "pyinstaller --clean led-cli.spec"
build = "pyinstaller --clean --onefile --name led-cli --paths lib cli.py"
install = "pipenv install"

View File

@@ -1,8 +1,40 @@
# led-tool
- `-s, --show`: Display current settings from device
- `--no-download`: Don't download settings first (use empty settings)
**Default behavior:** Downloads settings and prints them. If any edit flags are provided, settings are modified and uploaded automatically (unless `--no-upload` is specified).
## Device Connection
CLI helpers for MicroPython LED devices: **`settings.json`** download/upload via **mpremote**, resets, follow/tail, recursive uploads, optional firmware flash, and **led-driver**-oriented flags (presets, transport, Wi-Fi fields).
The tools use an integrated mpremote transport to communicate with MicroPython devices. Make sure your device is connected and accessible via the specified serial port.## LicenseSee LICENSE file for details.
Connection is always via **`-p` / `--port`** (default `/dev/ttyACM0`). There is **no** separate “server IP” flag; the Pi or PC address is configured on the device in **`settings.json`** when using Wi-Fi mode.
## Common flags
| Flag | Purpose |
|------|--------|
| `-p`, `--port` | Serial device (default `/dev/ttyACM0`) |
| `-s`, `--show` | Print current settings from the device |
| `-n`, `--name` | Device **name** |
| `--reset-device-name` | Set **name** to firmware default (`led-` + STA MAC hex, same as `Settings.set_defaults` on led-driver) |
| `--id` | Numeric device id (ESP-NOW, 0255) |
| `--pin` | LED GPIO (`led_pin`) |
| `-b`, `--brightness` | Brightness 0255 |
| `-l`, `--leds` | LED count (`num_leds`) |
| `-d`, `--debug` | Debug 0 or 1 |
| `-o`, `--order` | LED colour order (`rgb`, `grb`, …) |
| `--preset` / `--pattern` | Create or replace a named preset in **led-driver** `presets.json` |
| `--default` | Startup preset name |
| `--transport` | `espnow` or `wifi` (`transport_type` on device) |
| `--ssid`, `--wifi-password`, `--wifi-channel` | Wi-Fi / channel fields for the driver |
| `-r`, `--reset` | Reset the device |
| `-f`, `--follow` | Follow serial output (optional timeout seconds) |
| `--pause` | Sleep N seconds (for chained actions) |
| `-u`, `--upload` | Recursive upload: `-u SRC [DEST]` |
| `--src`, `--lib` | Upload `src/` or a tree to `/lib` |
| `-e` | Erase device code (keeps `settings.json`) |
| `--rm` | Remove a path on the device |
| `--flash` | Flash a firmware binary (uses **esptool** on the host) |
**Default behaviour:** the tool always downloads `settings.json`, prints the merged view, and uploads again **only** when you pass setting edits (`--name`, `--leds`, …), **`--preset`**, or the relevant upload/flash/erase actions in order.
Run **`python cli.py -h`** for the full epilog and argument list.
## License
See **LICENSE** in this directory.

312
cli.py
View File

@@ -11,11 +11,12 @@ import argparse
import subprocess
import sys
import time
import shutil
from typing import Dict, Any, List, Optional
import tempfile
import os
from device import copy_file, DeviceConnection
from device import copy_file, DeviceConnection, firmware_default_device_name
def resolve_flash_binary(path: str) -> Optional[str]:
@@ -84,6 +85,59 @@ def upload_settings(device: str, settings: dict) -> None:
pass
PRESETS_REMOTE = "presets.json"
def download_presets(device: str) -> dict:
"""Download presets.json from the device; missing file -> {}."""
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=PRESETS_REMOTE, local_path=temp_path)
with open(temp_path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, RuntimeError) as e:
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_presets(device: str, presets: dict, *, reset: bool = True) -> None:
"""Upload presets.json; optional reset (skip when caller will reset via settings upload)."""
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
temp_path = temp_file.name
try:
json.dump(presets, temp_file, indent=2)
temp_file.close()
copy_file(from_device=False, device=device, remote_path=PRESETS_REMOTE, local_path=temp_path)
if reset:
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 print_settings(settings: Dict[str, Any]) -> None:
"""Pretty print settings dictionary."""
print(json.dumps(settings, indent=2))
@@ -93,14 +147,15 @@ def print_settings(settings: Dict[str, Any]) -> None:
_FLAGS_WITH_VALUE = frozenset({
'-p', '--port', '-n', '--name', '--pin', '-b', '--brightness',
'-l', '--leds', '-d', '-debug', '--debug', '-o', '--order',
'--preset', '--default',
'--preset', '--pattern', '--default', '--transport', '--ssid',
'--wifi-password', '--wifi-channel', '--src', '--lib', '--patterns', '--paterns',
})
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: flash, pause, reset, upload, ls, erase_all, rm, follow.
"""
actions = []
i = 1
@@ -147,22 +202,47 @@ def _get_ordered_actions(argv: List[str]) -> List[tuple]:
i += 2
else:
i += 1
# Use empty string as remote_dir to map to root on device
actions.append(('upload', [local_dir, ""]))
# Upload source tree excluding patterns/ (handled by --patterns).
actions.append(('upload_src_no_patterns', local_dir))
continue
if arg == '--lib':
# Upload local DIR to /lib on device
if i + 1 < len(argv):
if arg in ('--patterns', '--paterns'):
# Upload local patterns DIR (default: ./src/patterns) to /patterns.
local_dir = os.path.join("src", "patterns")
if i + 1 < len(argv) and not argv[i + 1].startswith('-'):
local_dir = argv[i + 1]
actions.append(('upload', [local_dir, "lib"]))
i += 2
else:
raise ValueError("--lib requires a directory argument")
i += 1
actions.append(('upload', [local_dir, "patterns"]))
continue
if arg == '--all':
actions.append(('upload_src_no_patterns', "src"))
actions.append(('upload', [os.path.join("src", "patterns"), "patterns"]))
actions.append(('upload', ["lib", "lib"]))
i += 1
continue
if arg == '--lib':
# Upload local DIR (default: ./lib) to /lib on device
local_dir = "lib"
if i + 1 < len(argv) and not argv[i + 1].startswith('-'):
local_dir = argv[i + 1]
i += 2
else:
i += 1
actions.append(('upload', [local_dir, "lib"]))
continue
if arg == '--ls':
actions.append(('ls', None))
i += 1
continue
if arg == '-e':
actions.append(('erase_all', None))
i += 1
continue
if arg == '--erase':
actions.append(('erase_all', None))
i += 1
continue
if arg == '--rm':
if i + 1 < len(argv):
actions.append(('rm', argv[i + 1]))
@@ -229,6 +309,9 @@ Examples:
# Set name, num_leds, default pattern, and upload
%(prog)s --name "MyStrip" -l 60 --default rainbow
# Reset logical device name to firmware default (STA MAC based)
%(prog)s --reset-device-name
"""
)
@@ -243,6 +326,12 @@ Examples:
help="Device name"
)
parser.add_argument(
"--reset-device-name",
action="store_true",
help="Set name to firmware default (led-<STA MAC hex>, same as fresh settings.json on led-driver)",
)
parser.add_argument(
"--id",
dest="device_id",
@@ -291,13 +380,48 @@ Examples:
parser.add_argument(
"--preset",
help="Pattern preset name"
metavar="NAME",
help="Create or replace preset NAME in presets.json (led-driver; use --pattern)",
)
parser.add_argument(
"--pattern",
choices=[
"off", "on", "blink", "rainbow", "pulse", "transition", "chase", "circle",
],
help="Pattern type for --preset (default: on)",
)
parser.add_argument(
"--default",
metavar="PATTERN",
help="Default/startup pattern (stored as 'default' in settings.json)"
metavar="NAME",
help="Default/startup preset name in settings.json (which preset runs at boot)",
)
parser.add_argument(
"--transport",
choices=["espnow", "wifi"],
help="led-driver transport_type",
)
parser.add_argument(
"--ssid",
help="led-driver ssid (Wi-Fi network in wifi mode)",
)
parser.add_argument(
"--wifi-password",
dest="wifi_password",
metavar="PASS",
help="led-driver password (Wi-Fi PSK in wifi mode)",
)
parser.add_argument(
"--wifi-channel",
dest="wifi_channel",
type=int,
metavar="1-11",
help="led-driver wifi_channel (ESP-NOW mode)",
)
parser.add_argument(
@@ -340,17 +464,40 @@ Examples:
nargs="?",
const="src",
metavar="DIR",
help="Upload DIR recursively to device root (:/, no leading directory). If DIR is omitted, uses local ./src."
help="Upload DIR recursively to device root (:/, no leading directory), excluding patterns/. If DIR is omitted, uses local ./src."
)
parser.add_argument(
"--lib",
nargs="?",
const="lib",
metavar="DIR",
help="Upload DIR recursively to /lib on device"
help="Upload DIR recursively to /lib on device. If DIR is omitted, uses local ./lib."
)
parser.add_argument(
"-e",
"--all",
action="store_true",
help="Upload ./src (excluding patterns), ./src/patterns, and ./lib."
)
parser.add_argument(
"--patterns", "--paterns",
dest="patterns_dir",
nargs="?",
const=os.path.join("src", "patterns"),
metavar="DIR",
help="Upload DIR recursively to /patterns on device. If DIR is omitted, uses local ./src/patterns."
)
parser.add_argument(
"--ls",
action="store_true",
help="List files on the device root (:/)"
)
parser.add_argument(
"-e", "--erase",
dest="erase_all",
action="store_true",
help="Erase all code on the device (delete all files except settings.json)"
@@ -437,6 +584,50 @@ Examples:
except Exception as e:
print(f"Error uploading directory: {e}", file=sys.stderr)
sys.exit(1)
elif action_name == 'upload_src_no_patterns':
src_dir = value
if not os.path.exists(src_dir):
print(f"Error: Directory does not exist: {src_dir}", file=sys.stderr)
sys.exit(1)
if not os.path.isdir(src_dir):
print(f"Error: Not a directory: {src_dir}", file=sys.stderr)
sys.exit(1)
try:
with tempfile.TemporaryDirectory() as temp_src:
for entry in sorted(os.listdir(src_dir)):
if entry == "patterns":
continue
src_entry = os.path.join(src_dir, entry)
dst_entry = os.path.join(temp_src, entry)
if os.path.isdir(src_entry):
shutil.copytree(src_entry, dst_entry)
else:
shutil.copy2(src_entry, dst_entry)
print(
f"Uploading {src_dir} (excluding patterns/) to device root on {port}...",
file=sys.stderr,
)
conn = DeviceConnection(port)
files_copied, dirs_created = conn.upload_directory(temp_src, "")
print(
f"Upload complete: {files_copied} files, {dirs_created} directories created.",
file=sys.stderr,
)
except Exception as e:
print(f"Error uploading src (excluding patterns): {e}", file=sys.stderr)
sys.exit(1)
elif action_name == 'ls':
try:
print(f"Listing files on device {port}...", file=sys.stderr)
conn = DeviceConnection(port)
items = conn.list_files('')
for name, is_dir, size in items:
marker = "d" if is_dir else "-"
print(f"{marker} {size:>8} {name}")
except Exception as e:
print(f"Error listing files: {e}", file=sys.stderr)
sys.exit(1)
elif action_name == 'erase_all':
try:
@@ -480,11 +671,35 @@ Examples:
sys.exit(1)
return # follow blocks; when interrupted we're done
default_name_from_device: Optional[str] = None
if args.reset_device_name:
if args.name is not None:
print(
"Error: use either --name or --reset-device-name, not both.",
file=sys.stderr,
)
sys.exit(1)
try:
print(
f"Reading firmware default device name (STA MAC) on {port}...",
file=sys.stderr,
)
default_name_from_device = firmware_default_device_name(port)
print(
f"Default name will be {default_name_from_device!r}.",
file=sys.stderr,
)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Collect all edit parameters
edits: Dict[str, Any] = {}
if args.name is not None:
edits["name"] = args.name
elif default_name_from_device is not None:
edits["name"] = default_name_from_device
if args.pin is not None:
edits["led_pin"] = args.pin
@@ -501,12 +716,21 @@ Examples:
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.default is not None:
edits["default"] = args.default
if args.transport is not None:
edits["transport_type"] = args.transport
if args.ssid is not None:
edits["ssid"] = args.ssid
if args.wifi_password is not None:
edits["password"] = args.wifi_password
if args.wifi_channel is not None:
edits["wifi_channel"] = max(1, min(11, args.wifi_channel))
if args.device_id is not None:
# Clamp into single-byte range; store as int in settings.json
edits["id"] = max(0, min(255, args.device_id))
@@ -523,21 +747,55 @@ Examples:
print(f"Error downloading settings: {e}", file=sys.stderr)
sys.exit(1)
# If --show only, print and exit
# If --show only, print and exit (unless presets or settings are being written)
if args.show:
print_settings(settings)
if not edits:
if not edits and args.preset is None:
return
# 2. Edit: apply edits to downloaded settings
if edits:
print(f"Applying {len(edits)} edit(s)...", file=sys.stderr)
settings.update(edits)
# 2. Edit: only apply/upload settings when values actually change
changed_edits: Dict[str, Any] = {}
for key, value in edits.items():
if settings.get(key) != value:
changed_edits[key] = value
if edits and not changed_edits:
print("No settings changes detected; skipping settings upload.", file=sys.stderr)
if changed_edits:
print(f"Applying {len(changed_edits)} setting change(s)...", file=sys.stderr)
settings.update(changed_edits)
print_settings(settings)
# 3. Upload: write settings back to device when edits were made
if edits:
# 3a. Presets file (led-driver presets.json)
if args.preset is not None:
pattern = args.pattern if args.pattern is not None else "on"
try:
print(f"Downloading presets from {args.port}...", file=sys.stderr)
presets_data = download_presets(args.port)
entry = presets_data.get(args.preset)
if not isinstance(entry, dict):
entry = {}
entry["p"] = pattern
presets_data[args.preset] = entry
print(
f"Writing preset {args.preset!r} (pattern={pattern}) to {PRESETS_REMOTE}...",
file=sys.stderr,
)
upload_presets(args.port, presets_data, reset=not bool(edits))
print("Presets uploaded successfully.", file=sys.stderr)
if not edits:
print("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 presets: {e}", file=sys.stderr)
sys.exit(1)
# 3b. Settings upload (resets device)
if changed_edits:
try:
print(f"\nUploading settings to {args.port}...", file=sys.stderr)
upload_settings(args.port, settings)

View File

@@ -23,12 +23,15 @@ if lib_path not in sys.path and os.path.exists(lib_path):
sys.path.insert(0, lib_path)
from mpremote.transport_serial import SerialTransport
from mpremote.transport import TransportError
from mpremote.transport import TransportError, TransportExecError
class DeviceConnection:
"""Wrapper for device communication."""
#: Feed interval during uploads (led-driver uses WDT(timeout=10000) ms).
WDT_FEED_INTERVAL_SEC = 5.0
def __init__(self, device):
"""Connect to a device."""
self.device = device
@@ -54,6 +57,38 @@ class DeviceConnection:
pass
self.transport = None
def _feed_wdt(self) -> None:
"""Best-effort feed of the ESP task WDT between chunked FS writes."""
if self.transport is None:
return
try:
self.transport.exec(
"try:\n import machine\n machine.WDT(timeout=10000).feed()\nexcept Exception:\n pass\n"
)
except Exception:
pass
def _make_wdt_upload_progress_callback(self):
"""Progress hook for Transport.fs_writefile: feed WDT every WDT_FEED_INTERVAL_SEC."""
last_feed = [time.monotonic()]
def progress(written: int, total: int) -> None:
now = time.monotonic()
if now - last_feed[0] >= self.WDT_FEED_INTERVAL_SEC:
self._feed_wdt()
last_feed[0] = now
return progress
def _fs_writefile_with_wdt(self, remote_path: str, data: bytes) -> None:
"""Write file to device with periodic WDT feeds during long transfers."""
self._feed_wdt()
self.transport.fs_writefile(
remote_path,
data,
progress_callback=self._make_wdt_upload_progress_callback(),
)
def copy_from_device(self, remote_path, local_path):
"""Copy a file from device to local filesystem."""
self.connect()
@@ -70,7 +105,7 @@ class DeviceConnection:
try:
with open(local_path, 'rb') as f:
data = f.read()
self.transport.fs_writefile(remote_path, data)
self._fs_writefile_with_wdt(remote_path, data)
finally:
self.disconnect()
@@ -133,7 +168,7 @@ class DeviceConnection:
print(f"Uploading: {remote_file}", file=sys.stderr)
with open(local_file, 'rb') as f:
data = f.read()
self.transport.fs_writefile(remote_file, data)
self._fs_writefile_with_wdt(remote_file, data)
files_copied += 1
return files_copied, dirs_created
@@ -235,6 +270,37 @@ class DeviceConnection:
raise TransportError(f"Failed to follow output: {e}") from e
def firmware_default_device_name(device: str) -> str:
"""
Default logical device name on led-driver firmware: led-<sta_mac_hex>,
matching Settings.set_defaults() in led-driver/src/settings.py.
"""
conn = DeviceConnection(device)
conn.connect()
try:
code = (
"import network, ubinascii\n"
"sta = network.WLAN(network.STA_IF)\n"
"sta.active(True)\n"
"mac = ubinascii.hexlify(sta.config('mac')).decode().lower()\n"
"print('led-' + mac)\n"
)
out = conn.transport.exec(code)
except TransportExecError as e:
raise RuntimeError(
"Could not read STA MAC from device (is this MicroPython with network?): "
+ (e.error_output or str(e)).strip()
) from e
finally:
conn.disconnect()
text = out.decode("utf-8", errors="replace").strip()
for line in reversed(text.splitlines()):
line = line.strip()
if line.startswith("led-"):
return line
raise RuntimeError("Device did not return a led-<mac> default name")
def copy_file(from_device, device, remote_path, local_path):
"""
Copy a file to/from device.

View File

@@ -10,7 +10,7 @@ pipenv install "$@"
if [ ! -f "dist/led-cli" ] || [ "cli.py" -nt "dist/led-cli" ] || [ "device.py" -nt "dist/led-cli" ]; then
echo ""
echo "Building binary..."
pipenv run pyinstaller --clean led-cli.spec
pipenv run pyinstaller --clean --onefile --name led-cli --paths lib cli.py
fi
# Ensure ~/.local/bin exists