735 lines
23 KiB
Python
735 lines
23 KiB
Python
#!/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 subprocess
|
|
import os
|
|
from pathlib import Path
|
|
|
|
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 _run_mpremote_copy(from_device: bool, device: str, temp_path: str) -> None:
|
|
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:
|
|
"""Download settings.json from the device using mpremote."""
|
|
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)
|
|
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 using mpremote 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()
|
|
|
|
_run_mpremote_copy(from_device=False, device=device, temp_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")
|
|
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
|
|
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 subprocess.TimeoutExpired:
|
|
flash("Connection timeout. Check device connection.", "error")
|
|
status, status_type = "Connection timeout.", "error"
|
|
except FileNotFoundError:
|
|
flash("mpremote not found. Install with: pip install mpremote", "error")
|
|
status, status_type = "mpremote not found.", "error"
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
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 subprocess.TimeoutExpired:
|
|
flash("Connection timeout. Check device connection.", "error")
|
|
status, status_type = "Connection timeout.", "error"
|
|
except FileNotFoundError:
|
|
flash("mpremote not found. Install with: pip install mpremote", "error")
|
|
status, status_type = "mpremote not found.", "error"
|
|
except Exception as exc: # pylint: disable=broad-except
|
|
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()
|
|
|
|
|