Add LED tool: device, CLI, web UI, build scripts, and tests
- device.py: device communication/handling - cli.py: CLI interface updates - web.py: web interface - build.py, build.sh, install.sh: build and install scripts - Pipfile: Python dependencies - lib/mpremote: mpremote library - test_*.py: import and LED tests - Updated .gitignore and README Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
191
.gitignore
vendored
191
.gitignore
vendored
@@ -1,176 +1,37 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Build files
|
||||
build/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
|
||||
# Binary files
|
||||
*.bin
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# PyInstaller
|
||||
*.spec
|
||||
*.pyz
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Pipenv
|
||||
Pipfile.lock
|
||||
|
||||
20
Pipfile
Normal file
20
Pipfile
Normal file
@@ -0,0 +1,20 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
flask = "*"
|
||||
pyserial = "*"
|
||||
|
||||
[dev-packages]
|
||||
pyinstaller = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3"
|
||||
|
||||
[scripts]
|
||||
web = "python web.py"
|
||||
cli = "python cli.py"
|
||||
build = "pyinstaller --clean led-cli.spec"
|
||||
install = "pipenv install"
|
||||
@@ -1,2 +1,8 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
76
build.py
Normal file
76
build.py
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/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)
|
||||
37
build.sh
Normal file
37
build.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/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."
|
||||
|
||||
232
cli.py
232
cli.py
@@ -11,34 +11,32 @@ import argparse
|
||||
import sys
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# Import functions from tool.py
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
def _run_mpremote_copy(from_device: bool, device: str, temp_path: str) -> None:
|
||||
"""Run mpremote copy command."""
|
||||
if from_device:
|
||||
cmd = ["mpremote", "connect", device, "cp", ":/settings.json", temp_path]
|
||||
else:
|
||||
cmd = ["mpremote", "connect", device, "cp", temp_path, ":/settings.json"]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mpremote error: {result.stderr.strip() or result.stdout.strip()}")
|
||||
from device import copy_file, DeviceConnection
|
||||
|
||||
|
||||
def download_settings(device: str) -> dict:
|
||||
"""Download settings.json from the device using mpremote."""
|
||||
"""
|
||||
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:
|
||||
_run_mpremote_copy(from_device=True, device=device, temp_path=temp_path)
|
||||
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:
|
||||
@@ -48,7 +46,7 @@ def download_settings(device: str) -> dict:
|
||||
|
||||
|
||||
def upload_settings(device: str, settings: dict) -> None:
|
||||
"""Upload settings.json to the device using mpremote and reset device."""
|
||||
"""Upload settings.json to the device and reset device."""
|
||||
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
temp_path = temp_file.name
|
||||
|
||||
@@ -56,26 +54,16 @@ def upload_settings(device: str, settings: dict) -> None:
|
||||
json.dump(settings, temp_file, indent=2)
|
||||
temp_file.close()
|
||||
|
||||
_run_mpremote_copy(from_device=False, device=device, temp_path=temp_path)
|
||||
copy_file(from_device=False, device=device, remote_path="settings.json", local_path=temp_path)
|
||||
|
||||
# Reset device (best effort)
|
||||
try:
|
||||
import serial # type: ignore
|
||||
|
||||
with serial.Serial(device, baudrate=115200) as ser:
|
||||
ser.write(b"\x03\x03\x04")
|
||||
conn = DeviceConnection(device)
|
||||
conn.connect()
|
||||
conn.reset()
|
||||
conn.disconnect()
|
||||
except Exception:
|
||||
reset_cmd = [
|
||||
"mpremote",
|
||||
"connect",
|
||||
device,
|
||||
"exec",
|
||||
"import machine; machine.reset()",
|
||||
]
|
||||
try:
|
||||
subprocess.run(reset_cmd, capture_output=True, text=True, timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
@@ -130,6 +118,15 @@ Examples:
|
||||
|
||||
# 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
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -144,6 +141,12 @@ Examples:
|
||||
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,
|
||||
@@ -199,14 +202,145 @@ Examples:
|
||||
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
|
||||
|
||||
@@ -233,24 +367,27 @@ Examples:
|
||||
if value is not None:
|
||||
edits[attr_name] = value
|
||||
|
||||
# Download current settings (unless --no-download is specified)
|
||||
if args.no_download:
|
||||
# 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 FileNotFoundError:
|
||||
print("Error: mpremote not found. Install with: pip install mpremote", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error downloading settings: {e}", file=sys.stderr)
|
||||
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)
|
||||
@@ -269,14 +406,11 @@ Examples:
|
||||
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 FileNotFoundError:
|
||||
print("Error: mpremote not found. Install with: pip install mpremote", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Error: Connection timeout. Check device connection on {args.port}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error uploading settings: {e}", file=sys.stderr)
|
||||
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)
|
||||
|
||||
246
device.py
Normal file
246
device.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Device communication wrapper using integrated mpremote transport.
|
||||
|
||||
This module provides a simple interface for copying files to/from MicroPython devices
|
||||
without requiring mpremote to be installed as a separate dependency.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import serial
|
||||
|
||||
# Add lib directory to path - handle both normal execution and PyInstaller bundle
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running as a PyInstaller bundle
|
||||
_this_dir = sys._MEIPASS
|
||||
else:
|
||||
# Running as a normal Python script
|
||||
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
lib_path = os.path.join(_this_dir, 'lib')
|
||||
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
|
||||
|
||||
|
||||
class DeviceConnection:
|
||||
"""Wrapper for device communication."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Connect to a device."""
|
||||
self.device = device
|
||||
self.transport = None
|
||||
|
||||
def connect(self):
|
||||
"""Establish connection to device."""
|
||||
if self.transport is None:
|
||||
self.transport = SerialTransport(self.device, baudrate=115200)
|
||||
self.transport.enter_raw_repl()
|
||||
|
||||
def disconnect(self):
|
||||
"""Close connection to device."""
|
||||
if self.transport:
|
||||
try:
|
||||
if self.transport.in_raw_repl:
|
||||
self.transport.exit_raw_repl()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.transport = None
|
||||
|
||||
def copy_from_device(self, remote_path, local_path):
|
||||
"""Copy a file from device to local filesystem."""
|
||||
self.connect()
|
||||
try:
|
||||
data = self.transport.fs_readfile(remote_path)
|
||||
with open(local_path, 'wb') as f:
|
||||
f.write(data)
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def copy_to_device(self, local_path, remote_path):
|
||||
"""Copy a file from local filesystem to device."""
|
||||
self.connect()
|
||||
try:
|
||||
with open(local_path, 'rb') as f:
|
||||
data = f.read()
|
||||
self.transport.fs_writefile(remote_path, data)
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def upload_directory(self, local_dir, remote_dir=None):
|
||||
"""
|
||||
Upload a directory recursively to the device.
|
||||
|
||||
Args:
|
||||
local_dir: Local directory path to upload
|
||||
remote_dir: Remote directory path (default: root, uses basename of local_dir)
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.path.isdir(local_dir):
|
||||
raise ValueError(f"{local_dir} is not a directory")
|
||||
|
||||
self.connect()
|
||||
try:
|
||||
# Determine remote directory name
|
||||
if remote_dir is None:
|
||||
remote_dir = os.path.basename(os.path.abspath(local_dir))
|
||||
|
||||
# Ensure remote directory exists
|
||||
if not self.transport.fs_exists(remote_dir):
|
||||
self.transport.fs_mkdir(remote_dir)
|
||||
|
||||
# Walk through local directory and copy files
|
||||
files_copied = 0
|
||||
dirs_created = 0
|
||||
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
# Calculate relative path from local_dir
|
||||
rel_path = os.path.relpath(root, local_dir)
|
||||
|
||||
# Build remote path
|
||||
if rel_path == '.':
|
||||
remote_base = remote_dir.rstrip('/') or '/'
|
||||
else:
|
||||
rel_path_clean = rel_path.replace(os.sep, '/')
|
||||
if remote_dir.rstrip('/') == '':
|
||||
remote_base = '/' + rel_path_clean
|
||||
else:
|
||||
remote_base = '/'.join([remote_dir.rstrip('/'), rel_path_clean])
|
||||
|
||||
# Create remote directory if needed
|
||||
if rel_path != '.' and not self.transport.fs_exists(remote_base):
|
||||
print(f"Creating directory: {remote_base}", file=sys.stderr)
|
||||
self.transport.fs_mkdir(remote_base)
|
||||
dirs_created += 1
|
||||
|
||||
# Copy files
|
||||
for file in files:
|
||||
local_file = os.path.join(root, file)
|
||||
# Handle root directory case properly
|
||||
if remote_base == '/':
|
||||
remote_file = '/' + file
|
||||
else:
|
||||
remote_file = '/'.join([remote_base, file])
|
||||
|
||||
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)
|
||||
files_copied += 1
|
||||
|
||||
return files_copied, dirs_created
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def list_files(self, remote_path=''):
|
||||
"""
|
||||
List files and directories on the device.
|
||||
|
||||
Args:
|
||||
remote_path: Path on device to list (default: root directory)
|
||||
|
||||
Returns:
|
||||
List of (name, is_directory, size) tuples
|
||||
"""
|
||||
self.connect()
|
||||
try:
|
||||
if remote_path and not self.transport.fs_exists(remote_path):
|
||||
raise ValueError(f"Path does not exist: {remote_path}")
|
||||
|
||||
items = self.transport.fs_listdir(remote_path)
|
||||
result = []
|
||||
for item in items:
|
||||
is_dir = item.st_mode & 0o170000 == 0o040000
|
||||
result.append((item.name, is_dir, item.st_size))
|
||||
return result
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
def delete(self, remote_path, disconnect=True):
|
||||
"""
|
||||
Delete a file or directory on the device.
|
||||
|
||||
Args:
|
||||
remote_path: Path on device to delete (file or directory)
|
||||
disconnect: Whether to disconnect after deletion (default: True, set to False for recursive calls)
|
||||
"""
|
||||
self.connect()
|
||||
try:
|
||||
if not self.transport.fs_exists(remote_path):
|
||||
raise ValueError(f"Path does not exist: {remote_path}")
|
||||
|
||||
if self.transport.fs_isdir(remote_path):
|
||||
# Delete directory recursively
|
||||
# First, delete all files and subdirectories
|
||||
items = self.transport.fs_listdir(remote_path)
|
||||
for item in items:
|
||||
item_path = '/'.join([remote_path.rstrip('/'), item.name])
|
||||
if item.st_mode & 0o170000 == 0o040000: # Directory
|
||||
self.delete(item_path, disconnect=False) # Recursive delete, don't disconnect
|
||||
else: # File
|
||||
print(f"Deleting file: {item_path}", file=sys.stderr)
|
||||
self.transport.fs_rmfile(item_path)
|
||||
# Then delete the directory itself
|
||||
print(f"Deleting directory: {remote_path}", file=sys.stderr)
|
||||
self.transport.fs_rmdir(remote_path)
|
||||
else:
|
||||
# Delete file
|
||||
print(f"Deleting file: {remote_path}", file=sys.stderr)
|
||||
self.transport.fs_rmfile(remote_path)
|
||||
finally:
|
||||
if disconnect:
|
||||
self.disconnect()
|
||||
|
||||
def reset(self):
|
||||
"""Reset the device using serial commands (Ctrl+C, Ctrl+C, Ctrl+D)."""
|
||||
# Use direct serial connection like dev.py does
|
||||
try:
|
||||
with serial.Serial(self.device, baudrate=115200) as ser:
|
||||
ser.write(b'\x03\x03\x04') # Ctrl+C, Ctrl+C, Ctrl+D
|
||||
except Exception as e:
|
||||
raise TransportError(f"Failed to reset device: {e}") from e
|
||||
|
||||
def follow_output(self):
|
||||
"""Follow device output continuously (like tail -f)."""
|
||||
# Use direct serial connection like dev.py does
|
||||
try:
|
||||
with serial.Serial(self.device, baudrate=115200) as ser:
|
||||
while True:
|
||||
if ser.in_waiting > 0:
|
||||
data = ser.readline().decode('utf-8', errors='replace').strip()
|
||||
if data:
|
||||
print(data)
|
||||
else:
|
||||
time.sleep(0.01) # Small delay to avoid busy-waiting
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped following output.", file=sys.stderr)
|
||||
except Exception as e:
|
||||
raise TransportError(f"Failed to follow output: {e}") from e
|
||||
|
||||
|
||||
def copy_file(from_device, device, remote_path, local_path):
|
||||
"""
|
||||
Copy a file to/from device.
|
||||
|
||||
Args:
|
||||
from_device: True to copy from device, False to copy to device
|
||||
device: Device path (e.g., '/dev/ttyACM0')
|
||||
remote_path: Path on device (e.g., 'settings.json')
|
||||
local_path: Path on local filesystem
|
||||
"""
|
||||
conn = DeviceConnection(device)
|
||||
try:
|
||||
if from_device:
|
||||
conn.copy_from_device(remote_path, local_path)
|
||||
else:
|
||||
conn.copy_to_device(local_path, remote_path)
|
||||
except TransportError as e:
|
||||
raise RuntimeError(f"Device communication error: {e}") from e
|
||||
32
install.sh
Executable file
32
install.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install script - installs dependencies and copies binary to ~/.local/bin
|
||||
|
||||
set -e
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pipenv install "$@"
|
||||
|
||||
# Build binary if it doesn't exist or if source is newer
|
||||
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
|
||||
fi
|
||||
|
||||
# Ensure ~/.local/bin exists
|
||||
mkdir -p ~/.local/bin
|
||||
|
||||
# Copy binary to ~/.local/bin
|
||||
if [ -f "dist/led-cli" ]; then
|
||||
echo ""
|
||||
echo "Installing binary to ~/.local/bin/led-cli..."
|
||||
cp dist/led-cli ~/.local/bin/led-cli
|
||||
chmod +x ~/.local/bin/led-cli
|
||||
echo "✓ Binary installed successfully!"
|
||||
echo ""
|
||||
echo "You can now run 'led-cli' from anywhere."
|
||||
echo "Make sure ~/.local/bin is in your PATH."
|
||||
else
|
||||
echo "Error: Binary not found at dist/led-cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
1
lib/mpremote/__init__.py
Normal file
1
lib/mpremote/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Empty __init__.py to make this a package
|
||||
4
lib/mpremote/console.py
Normal file
4
lib/mpremote/console.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Minimal console module for mpremote compatibility
|
||||
# Only exports VT_ENABLED which is used by transport_serial
|
||||
|
||||
VT_ENABLED = False # Disable VT for non-interactive use
|
||||
55
lib/mpremote/mp_errno.py
Normal file
55
lib/mpremote/mp_errno.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import errno
|
||||
import platform
|
||||
|
||||
# This table maps numeric values defined by `py/mperrno.h` to host errno code.
|
||||
MP_ERRNO_TABLE = {
|
||||
1: errno.EPERM,
|
||||
2: errno.ENOENT,
|
||||
3: errno.ESRCH,
|
||||
4: errno.EINTR,
|
||||
5: errno.EIO,
|
||||
6: errno.ENXIO,
|
||||
7: errno.E2BIG,
|
||||
8: errno.ENOEXEC,
|
||||
9: errno.EBADF,
|
||||
10: errno.ECHILD,
|
||||
11: errno.EAGAIN,
|
||||
12: errno.ENOMEM,
|
||||
13: errno.EACCES,
|
||||
14: errno.EFAULT,
|
||||
16: errno.EBUSY,
|
||||
17: errno.EEXIST,
|
||||
18: errno.EXDEV,
|
||||
19: errno.ENODEV,
|
||||
20: errno.ENOTDIR,
|
||||
21: errno.EISDIR,
|
||||
22: errno.EINVAL,
|
||||
23: errno.ENFILE,
|
||||
24: errno.EMFILE,
|
||||
25: errno.ENOTTY,
|
||||
26: errno.ETXTBSY,
|
||||
27: errno.EFBIG,
|
||||
28: errno.ENOSPC,
|
||||
29: errno.ESPIPE,
|
||||
30: errno.EROFS,
|
||||
31: errno.EMLINK,
|
||||
32: errno.EPIPE,
|
||||
33: errno.EDOM,
|
||||
34: errno.ERANGE,
|
||||
95: errno.EOPNOTSUPP,
|
||||
97: errno.EAFNOSUPPORT,
|
||||
98: errno.EADDRINUSE,
|
||||
103: errno.ECONNABORTED,
|
||||
104: errno.ECONNRESET,
|
||||
105: errno.ENOBUFS,
|
||||
106: errno.EISCONN,
|
||||
107: errno.ENOTCONN,
|
||||
110: errno.ETIMEDOUT,
|
||||
111: errno.ECONNREFUSED,
|
||||
113: errno.EHOSTUNREACH,
|
||||
114: errno.EALREADY,
|
||||
115: errno.EINPROGRESS,
|
||||
125: errno.ECANCELED,
|
||||
}
|
||||
if platform.system() != "Windows":
|
||||
MP_ERRNO_TABLE[15] = errno.ENOTBLK
|
||||
210
lib/mpremote/transport.py
Normal file
210
lib/mpremote/transport.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# This file is part of the MicroPython project, http://micropython.org/
|
||||
#
|
||||
# The MIT License (MIT)
|
||||
#
|
||||
# Copyright (c) 2023 Jim Mussared
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
import ast, errno, hashlib, os, re, sys
|
||||
from collections import namedtuple
|
||||
from mpremote.mp_errno import MP_ERRNO_TABLE
|
||||
|
||||
|
||||
def stdout_write_bytes(b):
|
||||
b = b.replace(b"\x04", b"")
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout.buffer.write(b)
|
||||
sys.stdout.buffer.flush()
|
||||
else:
|
||||
text = b.decode(sys.stdout.encoding, "strict")
|
||||
sys.stdout.write(text)
|
||||
|
||||
|
||||
class TransportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TransportExecError(TransportError):
|
||||
def __init__(self, status_code, error_output):
|
||||
self.status_code = status_code
|
||||
self.error_output = error_output
|
||||
super().__init__(error_output)
|
||||
|
||||
|
||||
listdir_result = namedtuple("dir_result", ["name", "st_mode", "st_ino", "st_size"])
|
||||
|
||||
|
||||
# Takes a Transport error (containing the text of an OSError traceback) and
|
||||
# raises it as the corresponding OSError-derived exception.
|
||||
def _convert_filesystem_error(e, info):
|
||||
if "OSError" in e.error_output:
|
||||
for code, estr in [
|
||||
*errno.errorcode.items(),
|
||||
(errno.EOPNOTSUPP, "EOPNOTSUPP"),
|
||||
]:
|
||||
if estr in e.error_output:
|
||||
return OSError(code, info)
|
||||
|
||||
# Some targets don't render OSError with the name of the errno, so in these
|
||||
# cases support an explicit mapping of errnos to known numeric codes.
|
||||
error_lines = e.error_output.splitlines()
|
||||
match = re.match(r"OSError: (\d+)$", error_lines[-1])
|
||||
if match:
|
||||
value = int(match.group(1), 10)
|
||||
if value in MP_ERRNO_TABLE:
|
||||
return OSError(MP_ERRNO_TABLE[value], info)
|
||||
|
||||
return e
|
||||
|
||||
|
||||
class Transport:
|
||||
def fs_listdir(self, src=""):
|
||||
buf = bytearray()
|
||||
|
||||
def repr_consumer(b):
|
||||
buf.extend(b.replace(b"\x04", b""))
|
||||
|
||||
cmd = "import os\nfor f in os.ilistdir(%s):\n print(repr(f), end=',')" % (
|
||||
("'%s'" % src) if src else ""
|
||||
)
|
||||
try:
|
||||
buf.extend(b"[")
|
||||
self.exec(cmd, data_consumer=repr_consumer)
|
||||
buf.extend(b"]")
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, src) from None
|
||||
|
||||
return [
|
||||
listdir_result(*f) if len(f) == 4 else listdir_result(*(f + (0,)))
|
||||
for f in ast.literal_eval(buf.decode())
|
||||
]
|
||||
|
||||
def fs_stat(self, src):
|
||||
try:
|
||||
self.exec("import os")
|
||||
return os.stat_result(self.eval("os.stat(%s)" % ("'%s'" % src)))
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, src) from None
|
||||
|
||||
def fs_exists(self, src):
|
||||
try:
|
||||
self.fs_stat(src)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def fs_isdir(self, src):
|
||||
try:
|
||||
mode = self.fs_stat(src).st_mode
|
||||
return (mode & 0x4000) != 0
|
||||
except OSError:
|
||||
# Match CPython, a non-existent path is not a directory.
|
||||
return False
|
||||
|
||||
def fs_printfile(self, src, chunk_size=256):
|
||||
cmd = (
|
||||
"with open('%s') as f:\n while 1:\n"
|
||||
" b=f.read(%u)\n if not b:break\n print(b,end='')" % (src, chunk_size)
|
||||
)
|
||||
try:
|
||||
self.exec(cmd, data_consumer=stdout_write_bytes)
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, src) from None
|
||||
|
||||
def fs_readfile(self, src, chunk_size=256, progress_callback=None):
|
||||
if progress_callback:
|
||||
src_size = self.fs_stat(src).st_size
|
||||
|
||||
contents = bytearray()
|
||||
|
||||
try:
|
||||
self.exec("f=open('%s','rb')\nr=f.read" % src)
|
||||
while True:
|
||||
chunk = self.eval("r({})".format(chunk_size))
|
||||
if not chunk:
|
||||
break
|
||||
contents.extend(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(len(contents), src_size)
|
||||
self.exec("f.close()")
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, src) from None
|
||||
|
||||
return contents
|
||||
|
||||
def fs_writefile(self, dest, data, chunk_size=256, progress_callback=None):
|
||||
if progress_callback:
|
||||
src_size = len(data)
|
||||
written = 0
|
||||
|
||||
try:
|
||||
self.exec("f=open('%s','wb')\nw=f.write" % dest)
|
||||
while data:
|
||||
chunk = data[:chunk_size]
|
||||
self.exec("w(" + repr(chunk) + ")")
|
||||
data = data[len(chunk) :]
|
||||
if progress_callback:
|
||||
written += len(chunk)
|
||||
progress_callback(written, src_size)
|
||||
self.exec("f.close()")
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, dest) from None
|
||||
|
||||
def fs_mkdir(self, path):
|
||||
try:
|
||||
self.exec("import os\nos.mkdir('%s')" % path)
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, path) from None
|
||||
|
||||
def fs_rmdir(self, path):
|
||||
try:
|
||||
self.exec("import os\nos.rmdir('%s')" % path)
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, path) from None
|
||||
|
||||
def fs_rmfile(self, path):
|
||||
try:
|
||||
self.exec("import os\nos.remove('%s')" % path)
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, path) from None
|
||||
|
||||
def fs_touchfile(self, path):
|
||||
try:
|
||||
self.exec("f=open('%s','a')\nf.close()" % path)
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, path) from None
|
||||
|
||||
def fs_hashfile(self, path, algo, chunk_size=256):
|
||||
try:
|
||||
self.exec("import hashlib\nh = hashlib.{algo}()".format(algo=algo))
|
||||
except TransportExecError:
|
||||
# hashlib (or hashlib.{algo}) not available on device. Do the hash locally.
|
||||
data = self.fs_readfile(path, chunk_size=chunk_size)
|
||||
return getattr(hashlib, algo)(data).digest()
|
||||
try:
|
||||
self.exec(
|
||||
"buf = memoryview(bytearray({chunk_size}))\nwith open('{path}', 'rb') as f:\n while True:\n n = f.readinto(buf)\n if n == 0:\n break\n h.update(buf if n == {chunk_size} else buf[:n])\n".format(
|
||||
chunk_size=chunk_size, path=path
|
||||
)
|
||||
)
|
||||
return self.eval("h.digest()")
|
||||
except TransportExecError as e:
|
||||
raise _convert_filesystem_error(e, path) from None
|
||||
1053
lib/mpremote/transport_serial.py
Normal file
1053
lib/mpremote/transport_serial.py
Normal file
File diff suppressed because it is too large
Load Diff
43
test_imports.py
Normal file
43
test_imports.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify imports work"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Test 1: Check if file exists
|
||||
transport_serial_path = 'lib/mpremote/transport_serial.py'
|
||||
print(f"1. Checking if {transport_serial_path} exists...")
|
||||
if os.path.exists(transport_serial_path):
|
||||
print(f" ✓ File exists ({os.path.getsize(transport_serial_path)} bytes)")
|
||||
else:
|
||||
print(f" ✗ File does NOT exist!")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Add lib to path and import
|
||||
print("2. Testing imports...")
|
||||
sys.path.insert(0, 'lib')
|
||||
try:
|
||||
from mpremote.transport_serial import SerialTransport
|
||||
print(" ✓ transport_serial import works")
|
||||
except Exception as e:
|
||||
print(f" ✗ transport_serial import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Import device module
|
||||
try:
|
||||
import device
|
||||
print(" ✓ device module import works")
|
||||
except Exception as e:
|
||||
print(f" ✗ device module import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 4: Import cli module
|
||||
try:
|
||||
import cli
|
||||
print(" ✓ cli module import works")
|
||||
except Exception as e:
|
||||
print(f" ✗ cli module import failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✓ All imports successful!")
|
||||
35
test_led_simple.py
Normal file
35
test_led_simple.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple test for LED count update"""
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Test the edit logic
|
||||
edits = {}
|
||||
args_leds = 100
|
||||
|
||||
if args_leds is not None:
|
||||
edits["num_leds"] = args_leds
|
||||
|
||||
print("Edits:", edits)
|
||||
|
||||
# Simulate --no-download
|
||||
settings = {}
|
||||
|
||||
# Apply edits
|
||||
if edits:
|
||||
print(f"Applying {len(edits)} edit(s)...")
|
||||
settings.update(edits)
|
||||
|
||||
# Print settings (this is what print_settings does)
|
||||
print("\nOutput JSON:")
|
||||
print(json.dumps(settings, indent=2))
|
||||
|
||||
# Verify
|
||||
if settings.get("num_leds") == 100:
|
||||
print("\n✓ SUCCESS: num_leds is correctly set to 100")
|
||||
else:
|
||||
print(f"\n✗ FAILED: num_leds is {settings.get('num_leds')}, expected 100")
|
||||
87
test_leds.py
Normal file
87
test_leds.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for updating number of LEDs"""
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
print("Testing CLI with -l (number of LEDs) option...")
|
||||
print("=" * 60)
|
||||
|
||||
# Test 1: Check help output
|
||||
print("\n1. Testing help output:")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "cli.py", "-h"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/jimmy/projects/led-tool"
|
||||
)
|
||||
if "-l" in result.stdout or "--leds" in result.stdout:
|
||||
print(" ✓ Help shows -l/--leds option")
|
||||
else:
|
||||
print(" ✗ Help does not show -l/--leds option")
|
||||
print(f" Output: {result.stdout[:200]}")
|
||||
|
||||
# Test 2: Test with --no-download and --no-upload (won't connect to device)
|
||||
print("\n2. Testing -l 100 with --no-download --no-upload:")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "cli.py", "-l", "100", "--no-download", "--no-upload"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/jimmy/projects/led-tool"
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
print(f" Return code: {result.returncode}")
|
||||
print(f" Output:\n{output}")
|
||||
|
||||
# Check if num_leds is in the output
|
||||
if "num_leds" in output or '"num_leds": 100' in output:
|
||||
print(" ✓ num_leds is set to 100 in output")
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
# Extract JSON from output (it should be the last part)
|
||||
lines = output.strip().split('\n')
|
||||
json_str = '\n'.join([l for l in lines if l.strip().startswith('{') or l.strip().startswith('"')])
|
||||
if not json_str:
|
||||
json_str = output.strip()
|
||||
settings = json.loads(json_str)
|
||||
if settings.get("num_leds") == 100:
|
||||
print(" ✓ JSON is valid and num_leds = 100")
|
||||
else:
|
||||
print(f" ✗ num_leds is {settings.get('num_leds')}, expected 100")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ⚠ Could not parse as JSON: {e}")
|
||||
print(f" Raw output: {output[:500]}")
|
||||
else:
|
||||
print(" ✗ num_leds not found in output")
|
||||
print(f" Output: {output[:500]}")
|
||||
|
||||
# Test 3: Test with multiple options including LEDs
|
||||
print("\n3. Testing -l 60 with other options:")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "cli.py", "-l", "60", "-b", "128", "-n", "TestLED", "--no-download", "--no-upload"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/jimmy/projects/led-tool"
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
settings = json.loads(output.strip())
|
||||
if settings.get("num_leds") == 60:
|
||||
print(" ✓ num_leds is set to 60")
|
||||
if settings.get("brightness") == 128:
|
||||
print(" ✓ brightness is set to 128")
|
||||
if settings.get("name") == "TestLED":
|
||||
print(" ✓ name is set to TestLED")
|
||||
print(f" Settings: {json.dumps(settings, indent=2)}")
|
||||
except json.JSONDecodeError:
|
||||
print(f" ⚠ Could not parse output as JSON")
|
||||
print(f" Output: {output[:500]}")
|
||||
else:
|
||||
print(f" ✗ Command failed with return code {result.returncode}")
|
||||
print(f" Error: {result.stderr}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test complete!")
|
||||
707
web.py
Normal file
707
web.py
Normal file
@@ -0,0 +1,707 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LED Bar Configuration Web App
|
||||
|
||||
Flask-based web UI for downloading, editing, and uploading settings.json
|
||||
to/from MicroPython devices via mpremote.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from device import copy_file, DeviceConnection
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
render_template_string,
|
||||
request,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
)
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "change-me-in-production"
|
||||
|
||||
|
||||
SETTINGS_CONFIG = [
|
||||
("led_pin", "LED Pin", "number"),
|
||||
("num_leds", "Number of LEDs", "number"),
|
||||
("color_order", "Color Order", "choice", ["rgb", "rbg", "grb", "gbr", "brg", "bgr"]),
|
||||
("name", "Device Name", "text"),
|
||||
("pattern", "Pattern", "text"),
|
||||
("delay", "Delay (ms)", "number"),
|
||||
("brightness", "Brightness", "number"),
|
||||
("n1", "N1", "number"),
|
||||
("n2", "N2", "number"),
|
||||
("n3", "N3", "number"),
|
||||
("n4", "N4", "number"),
|
||||
("n5", "N5", "number"),
|
||||
("n6", "N6", "number"),
|
||||
("ap_password", "AP Password", "text"),
|
||||
("id", "ID", "number"),
|
||||
("debug", "Debug Mode", "choice", ["True", "False"]),
|
||||
]
|
||||
|
||||
|
||||
def download_settings(device: str) -> dict:
|
||||
"""Download settings.json from the device."""
|
||||
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)
|
||||
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_settings_from_form(form) -> dict:
|
||||
settings = {}
|
||||
for cfg in SETTINGS_CONFIG:
|
||||
key = cfg[0]
|
||||
raw = (form.get(key) or "").strip()
|
||||
if raw == "":
|
||||
continue
|
||||
|
||||
if key in ["led_pin", "num_leds", "delay", "brightness", "id", "n1", "n2", "n3", "n4", "n5", "n6"]:
|
||||
try:
|
||||
settings[key] = int(raw)
|
||||
except ValueError:
|
||||
settings[key] = raw
|
||||
elif key == "debug":
|
||||
settings[key] = raw == "True"
|
||||
else:
|
||||
settings[key] = raw
|
||||
return settings
|
||||
|
||||
|
||||
TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>LED Bar Configuration</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1020;
|
||||
--bg-alt: #141b2f;
|
||||
--accent: #3b82f6;
|
||||
--accent-soft: rgba(59, 130, 246, 0.15);
|
||||
--border: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--danger: #f97373;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at top, #1f2937 0, #020617 55%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.shell {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
background: linear-gradient(145deg, #020617 0, #020617 40%, #030712 100%);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(15, 23, 42, 0.9),
|
||||
0 45px 80px rgba(15, 23, 42, 0.95),
|
||||
0 0 80px rgba(37, 99, 235, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.4), transparent 55%);
|
||||
}
|
||||
header h1 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
header h1 span.badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.6);
|
||||
background: rgba(37, 99, 235, 0.15);
|
||||
color: #bfdbfe;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chip {
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(55, 65, 81, 0.9);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
main {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.2fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.card {
|
||||
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 55%);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(31, 41, 55, 0.95);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(59, 130, 246, 0.4), transparent 55%),
|
||||
radial-gradient(circle at 100% 0, rgba(236, 72, 153, 0.28), transparent 55%);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.card > * { position: relative; z-index: 1; }
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
.card-header span.sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.6rem 0.75rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.95);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
transition: border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
border-color: rgba(59, 130, 246, 0.95);
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.65), 0 0 25px rgba(37, 99, 235, 0.6);
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
}
|
||||
.device-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.device-row input {
|
||||
flex: 1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 10px 25px rgba(37, 99, 235, 0.55),
|
||||
0 0 0 1px rgba(15, 23, 42, 0.95);
|
||||
white-space: nowrap;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease, opacity 0.1s ease;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: radial-gradient(circle at top, rgba(15, 23, 42, 0.95), rgba(17, 24, 39, 0.98));
|
||||
border-color: rgba(55, 65, 81, 0.9);
|
||||
color: var(--text);
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.85);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: rgba(55, 65, 81, 0.8);
|
||||
color: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(37, 99, 235, 0.75),
|
||||
0 0 0 1px rgba(191, 219, 254, 0.45);
|
||||
opacity: 0.97;
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow:
|
||||
0 10px 20px rgba(15, 23, 42, 0.9),
|
||||
0 0 0 1px rgba(30, 64, 175, 0.9);
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.status {
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.95);
|
||||
}
|
||||
.status.error .status-dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 14px rgba(248, 113, 113, 0.95);
|
||||
}
|
||||
.flash-container {
|
||||
position: fixed;
|
||||
right: 1.4rem;
|
||||
bottom: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 320px;
|
||||
z-index: 40;
|
||||
}
|
||||
.flash {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.78rem;
|
||||
backdrop-filter: blur(18px);
|
||||
background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.8), rgba(15, 23, 42, 0.96));
|
||||
border: 1px solid rgba(96, 165, 250, 0.8);
|
||||
color: #e5f0ff;
|
||||
box-shadow:
|
||||
0 22px 40px rgba(15, 23, 42, 0.95),
|
||||
0 0 30px rgba(37, 99, 235, 0.7);
|
||||
}
|
||||
.flash.error {
|
||||
background: radial-gradient(circle at top left, rgba(248, 113, 113, 0.85), rgba(15, 23, 42, 0.96));
|
||||
border-color: rgba(248, 113, 113, 0.8);
|
||||
}
|
||||
.flash small {
|
||||
display: block;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(55, 65, 81, 0.9);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header>
|
||||
<div>
|
||||
<h1>
|
||||
<span>LED Bar Configuration</span>
|
||||
<span class="badge">Web Console</span>
|
||||
</h1>
|
||||
<div class="chip-row">
|
||||
<span class="chip">
|
||||
<span style="width: 6px; height: 6px; border-radius: 999px; background: #22c55e; box-shadow: 0 0 12px rgba(34, 197, 94, 0.95);"></span>
|
||||
<span>Raspberry Pi · MicroPython</span>
|
||||
</span>
|
||||
<span class="chip">settings.json live editor</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip">Device: {{ device or "/dev/ttyACM0" }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Device Connection</h2>
|
||||
<span class="sub">Connect to your MicroPython LED controller and sync configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('handle_action') }}">
|
||||
<label for="device">Serial / mpremote device</label>
|
||||
<div class="device-row">
|
||||
<input
|
||||
id="device"
|
||||
name="device"
|
||||
type="text"
|
||||
value="{{ device or '/dev/ttyACM0' }}"
|
||||
placeholder="/dev/ttyACM0"
|
||||
required
|
||||
/>
|
||||
<button class="btn" type="submit" name="action" value="download">
|
||||
⬇ Download
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="upload">
|
||||
⬆ Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status {% if status_type == 'error' %}error{% endif %}">
|
||||
<span class="status-dot"></span>
|
||||
<span>{{ status or "Ready" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pill">
|
||||
<span>Tip:</span>
|
||||
<span>Download from device → tweak parameters → Upload and reboot.</span>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(31, 41, 55, 0.9); margin: 0.9rem 0 0.7rem;" />
|
||||
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>LED Settings</h2>
|
||||
<span class="sub">Edit all fields before uploading back to your controller</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
{% for field in settings_config %}
|
||||
{% set key, label, field_type = field[0], field[1], field[2] %}
|
||||
<div>
|
||||
<label for="{{ key }}">{{ label }}</label>
|
||||
{% if field_type == 'choice' %}
|
||||
{% set choices = field[3] %}
|
||||
<select id="{{ key }}" name="{{ key }}">
|
||||
<option value=""></option>
|
||||
{% for choice in choices %}
|
||||
{% if key == 'debug' %}
|
||||
{% set selected = 'selected' if (settings.get(key) is sameas true and choice == 'True') or (settings.get(key) is sameas false and choice == 'False') else '' %}
|
||||
{% else %}
|
||||
{% set selected = 'selected' if settings.get(key) == choice else '' %}
|
||||
{% endif %}
|
||||
<option value="{{ choice }}" {{ selected }}>{{ choice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input
|
||||
id="{{ key }}"
|
||||
name="{{ key }}"
|
||||
type="text"
|
||||
value="{{ settings.get(key, '') }}"
|
||||
/>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="clear">
|
||||
Reset form
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="submit" name="action" value="from_json">
|
||||
Paste JSON…
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>Raw JSON</h2>
|
||||
<span class="sub">For advanced editing, paste or copy the full settings.json</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('handle_action') }}">
|
||||
<input type="hidden" name="device" value="{{ device or '/dev/ttyACM0' }}" />
|
||||
<label for="raw_json">settings.json</label>
|
||||
<textarea
|
||||
id="raw_json"
|
||||
name="raw_json"
|
||||
rows="16"
|
||||
style="
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.95);
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
outline: none;
|
||||
"
|
||||
>{{ raw_json }}</textarea>
|
||||
|
||||
<div class="btn-row" style="margin-top: 0.75rem;">
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="to_form">
|
||||
Use JSON for form
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="submit" name="action" value="pretty">
|
||||
Pretty-print
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="flash-container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {% if category == 'error' %}error{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
return render_template_string(
|
||||
TEMPLATE,
|
||||
device="/dev/ttyACM0",
|
||||
settings={},
|
||||
settings_config=SETTINGS_CONFIG,
|
||||
status="Ready",
|
||||
status_type="ok",
|
||||
raw_json="{}",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/", methods=["POST"])
|
||||
def handle_action():
|
||||
action = request.form.get("action") or ""
|
||||
device = (request.form.get("device") or "/dev/ttyACM0").strip()
|
||||
raw_json = (request.form.get("raw_json") or "").strip()
|
||||
|
||||
settings = {}
|
||||
status = "Ready"
|
||||
status_type = "ok"
|
||||
|
||||
if action == "download":
|
||||
if not device:
|
||||
flash("Please specify a device.", "error")
|
||||
status, status_type = "Missing device.", "error"
|
||||
else:
|
||||
try:
|
||||
settings = download_settings(device)
|
||||
raw_json = json.dumps(settings, indent=2)
|
||||
flash(f"Settings downloaded from {device}.", "success")
|
||||
status = f"Settings downloaded from {device}"
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
if "timeout" in str(exc).lower() or "connection" in str(exc).lower():
|
||||
flash("Connection timeout. Check device connection.", "error")
|
||||
status, status_type = "Connection timeout.", "error"
|
||||
else:
|
||||
flash(f"Failed to download settings: {exc}", "error")
|
||||
status, status_type = "Download failed.", "error"
|
||||
|
||||
elif action == "upload":
|
||||
if not device:
|
||||
flash("Please specify a device.", "error")
|
||||
status, status_type = "Missing device.", "error"
|
||||
else:
|
||||
# Take current form fields as source of truth, falling back to JSON if present
|
||||
if raw_json:
|
||||
try:
|
||||
settings = json.loads(raw_json)
|
||||
except json.JSONDecodeError:
|
||||
flash("Raw JSON is invalid; using form values instead.", "error")
|
||||
settings = {}
|
||||
form_settings = parse_settings_from_form(request.form)
|
||||
settings.update(form_settings)
|
||||
|
||||
if not settings:
|
||||
flash("No settings to upload. Download or provide settings first.", "error")
|
||||
status, status_type = "No settings to upload.", "error"
|
||||
else:
|
||||
try:
|
||||
upload_settings(device, settings)
|
||||
raw_json = json.dumps(settings, indent=2)
|
||||
flash(f"Settings uploaded and device reset on {device}.", "success")
|
||||
status = f"Settings uploaded and device reset on {device}"
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
if "timeout" in str(exc).lower() or "connection" in str(exc).lower():
|
||||
flash("Connection timeout. Check device connection.", "error")
|
||||
status, status_type = "Connection timeout.", "error"
|
||||
else:
|
||||
flash(f"Failed to upload settings: {exc}", "error")
|
||||
status, status_type = "Upload failed.", "error"
|
||||
|
||||
elif action == "from_json":
|
||||
# No-op here, JSON is just edited in the side panel
|
||||
form_settings = parse_settings_from_form(request.form)
|
||||
settings.update(form_settings)
|
||||
if raw_json:
|
||||
try:
|
||||
settings.update(json.loads(raw_json))
|
||||
flash("JSON merged into form values.", "success")
|
||||
status = "JSON merged into form."
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid JSON; keeping previous form values.", "error")
|
||||
status, status_type = "JSON parse error.", "error"
|
||||
|
||||
elif action == "to_form":
|
||||
if raw_json:
|
||||
try:
|
||||
settings = json.loads(raw_json)
|
||||
flash("Form fields updated from JSON.", "success")
|
||||
status = "Form fields updated from JSON."
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid JSON; could not update form fields.", "error")
|
||||
status, status_type = "JSON parse error.", "error"
|
||||
|
||||
elif action == "pretty":
|
||||
if raw_json:
|
||||
try:
|
||||
parsed = json.loads(raw_json)
|
||||
raw_json = json.dumps(parsed, indent=2)
|
||||
settings = parsed if isinstance(parsed, dict) else {}
|
||||
flash("JSON pretty-printed.", "success")
|
||||
status = "JSON pretty-printed."
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid JSON; cannot pretty-print.", "error")
|
||||
status, status_type = "JSON parse error.", "error"
|
||||
|
||||
elif action == "clear":
|
||||
settings = {}
|
||||
raw_json = "{}"
|
||||
flash("Form cleared.", "success")
|
||||
status = "Form cleared."
|
||||
|
||||
else:
|
||||
# Unknown / initial action: just reflect form values back
|
||||
settings = parse_settings_from_form(request.form)
|
||||
if raw_json and not settings:
|
||||
try:
|
||||
settings = json.loads(raw_json)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return render_template_string(
|
||||
TEMPLATE,
|
||||
device=device,
|
||||
settings=settings,
|
||||
settings_config=SETTINGS_CONFIG,
|
||||
status=status,
|
||||
status_type=status_type,
|
||||
raw_json=raw_json or json.dumps(settings or {}, indent=2),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Bind to all interfaces so you can reach it from your LAN:
|
||||
# python web_app.py
|
||||
# Then open: http://<pi-ip>:5000/
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user