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
|
# Build files
|
||||||
# Byte-compiled / optimized / DLL files
|
build/
|
||||||
|
sdkconfig
|
||||||
|
sdkconfig.old
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.bin
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.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/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
*.egg-info/
|
||||||
env.bak/
|
dist/
|
||||||
venv.bak/
|
build/
|
||||||
|
|
||||||
# Spyder project settings
|
# PyInstaller
|
||||||
.spyderproject
|
*.spec
|
||||||
.spyproject
|
*.pyz
|
||||||
|
|
||||||
# Rope project settings
|
# IDE
|
||||||
.ropeproject
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# mkdocs documentation
|
# OS
|
||||||
/site
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
# 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
|
|
||||||
|
|
||||||
|
# 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
|
# 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."
|
||||||
|
|
||||||
226
cli.py
226
cli.py
@@ -11,34 +11,32 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
# Import functions from tool.py
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import subprocess
|
|
||||||
import os
|
import os
|
||||||
|
from device import copy_file, DeviceConnection
|
||||||
|
|
||||||
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()}")
|
|
||||||
|
|
||||||
|
|
||||||
def download_settings(device: str) -> dict:
|
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_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
temp_file.close()
|
temp_file.close()
|
||||||
|
|
||||||
try:
|
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:
|
with open(temp_path, "r", encoding="utf-8") as f:
|
||||||
return json.load(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:
|
finally:
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
try:
|
try:
|
||||||
@@ -48,7 +46,7 @@ def download_settings(device: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def upload_settings(device: str, settings: dict) -> None:
|
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_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
|
|
||||||
@@ -56,25 +54,15 @@ def upload_settings(device: str, settings: dict) -> None:
|
|||||||
json.dump(settings, temp_file, indent=2)
|
json.dump(settings, temp_file, indent=2)
|
||||||
temp_file.close()
|
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)
|
# Reset device (best effort)
|
||||||
try:
|
try:
|
||||||
import serial # type: ignore
|
conn = DeviceConnection(device)
|
||||||
|
conn.connect()
|
||||||
with serial.Serial(device, baudrate=115200) as ser:
|
conn.reset()
|
||||||
ser.write(b"\x03\x03\x04")
|
conn.disconnect()
|
||||||
except Exception:
|
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:
|
finally:
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
@@ -130,6 +118,15 @@ Examples:
|
|||||||
|
|
||||||
# Set number of LEDs, color order, and debug mode
|
# Set number of LEDs, color order, and debug mode
|
||||||
%(prog)s -l 60 -o rgb -d 1
|
%(prog)s -l 60 -o rgb -d 1
|
||||||
|
|
||||||
|
# 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"
|
help="Device name"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--pin",
|
||||||
|
type=int,
|
||||||
|
help="LED GPIO pin number (updates 'led_pin' in settings.json)"
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-b", "--brightness",
|
"-b", "--brightness",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -199,14 +202,145 @@ Examples:
|
|||||||
help="Don't download settings first (use empty settings)"
|
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()
|
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
|
# Collect all edit parameters
|
||||||
edits: Dict[str, Any] = {}
|
edits: Dict[str, Any] = {}
|
||||||
|
|
||||||
if args.name is not None:
|
if args.name is not None:
|
||||||
edits["name"] = args.name
|
edits["name"] = args.name
|
||||||
|
|
||||||
|
if args.pin is not None:
|
||||||
|
edits["led_pin"] = args.pin
|
||||||
|
|
||||||
if args.brightness is not None:
|
if args.brightness is not None:
|
||||||
edits["brightness"] = args.brightness
|
edits["brightness"] = args.brightness
|
||||||
|
|
||||||
@@ -233,24 +367,27 @@ Examples:
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
edits[attr_name] = value
|
edits[attr_name] = value
|
||||||
|
|
||||||
# Download current settings (unless --no-download is specified)
|
# Download current settings (unless --no-download is specified and not showing)
|
||||||
if args.no_download:
|
if args.no_download and not args.show:
|
||||||
settings = {}
|
settings = {}
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
print(f"Downloading settings from {args.port}...", file=sys.stderr)
|
print(f"Downloading settings from {args.port}...", file=sys.stderr)
|
||||||
settings = download_settings(args.port)
|
settings = download_settings(args.port)
|
||||||
print("Settings downloaded successfully.", file=sys.stderr)
|
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:
|
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)
|
print(f"Error downloading settings: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
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
|
# Apply edits
|
||||||
if edits:
|
if edits:
|
||||||
print(f"Applying {len(edits)} edit(s)...", file=sys.stderr)
|
print(f"Applying {len(edits)} edit(s)...", file=sys.stderr)
|
||||||
@@ -269,13 +406,10 @@ Examples:
|
|||||||
print(f"\nUploading settings to {args.port}...", file=sys.stderr)
|
print(f"\nUploading settings to {args.port}...", file=sys.stderr)
|
||||||
upload_settings(args.port, settings)
|
upload_settings(args.port, settings)
|
||||||
print("Settings uploaded successfully. Device will reset.", file=sys.stderr)
|
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:
|
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 settings: {e}", file=sys.stderr)
|
print(f"Error uploading settings: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif edits and args.no_upload:
|
elif edits and args.no_upload:
|
||||||
|
|||||||
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