- infrastructure/proxmox/: CT provisioning configs - infrastructure/fabric/gitops/: GitOps layer - fleet.yaml: GPU inventory (Dell GTX 1050 vfio-pci passthrough) - firmware/: ESP32 firmware tree (67 files, 6.9MB) - realms/savearth/: Savearth team realm with heteronyms - lab-gateway: ESP client + manager - CI: Forgejo + GitHub Actions workflows Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
410 lines
14 KiB
Python
410 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""esp_client.py — laptop-side client for the lab-gateway /esp/* endpoints.
|
|
|
|
Lets you flash ESP32 devices connected to a Proxmox host without local USB.
|
|
The gateway runs esptool on the host where the device is plugged in; this
|
|
client uploads the firmware bundle and streams the esptool output back.
|
|
|
|
Usage
|
|
-----
|
|
# 0. Point at the gateway (or set $LAB_GATEWAY)
|
|
export LAB_GATEWAY=http://192.168.0.38:8910
|
|
|
|
# 1. List USB serial ports visible on the gateway host
|
|
python3 esp_client.py ports
|
|
|
|
# 2. Probe chip / MAC
|
|
python3 esp_client.py chip --port /dev/ttyACM0
|
|
|
|
# 3. Erase flash (ROM download mode)
|
|
python3 esp_client.py erase --port /dev/ttyACM0
|
|
|
|
# 4. Flash from an idf.py build bundle (build_v28/, build/, etc.)
|
|
python3 esp_client.py flash --bundle build_v28 --port /dev/ttyACM0
|
|
python3 esp_client.py flash --bundle build_v28 --port /dev/ttyACM0 --erase
|
|
|
|
# 5. Live serial monitor (Ctrl-] to quit, like telnet)
|
|
python3 esp_client.py monitor --port /dev/ttyACM0
|
|
|
|
# 6. Inspect recent jobs
|
|
python3 esp_client.py jobs
|
|
python3 esp_client.py jobs --job-id <id>
|
|
|
|
Dependencies
|
|
------------
|
|
pip install requests websockets
|
|
|
|
The ``--bundle`` directory must contain an idf.py-generated ``flash_args``
|
|
(or ``flash_project_args``) plus the .bin files it references. Paths inside
|
|
flash_args are resolved relative to the bundle directory, matching idf.py.
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
sys.exit("missing dependency: pip install requests")
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
sys.exit("missing dependency: pip install websockets")
|
|
|
|
|
|
# ── flash_args parsing ──────────────────────────────────────────
|
|
|
|
def parse_flash_args(bundle_dir: str) -> tuple[list[tuple[str, str]], dict]:
|
|
"""Parse idf.py's ``<bundle>/flash_args`` (or ``flash_project_args``).
|
|
|
|
Returns ``(parts, opts)`` where ``parts`` is a list of
|
|
``(offset_str, abs_path)`` and ``opts`` is a dict of the esptool
|
|
write_flash flags (flash_mode, flash_size, flash_freq, ...).
|
|
"""
|
|
bdir = os.path.abspath(bundle_dir)
|
|
args_path = os.path.join(bdir, "flash_args")
|
|
if not os.path.isfile(args_path):
|
|
args_path = os.path.join(bdir, "flash_project_args")
|
|
if not os.path.isfile(args_path):
|
|
raise FileNotFoundError(
|
|
f"no flash_args (or flash_project_args) in {bdir}"
|
|
)
|
|
|
|
parts: list[tuple[str, str]] = []
|
|
opts: dict[str, str] = {}
|
|
with open(args_path) as f:
|
|
lines = [ln.strip() for ln in f if ln.strip()]
|
|
|
|
for line in lines:
|
|
toks = line.split()
|
|
if not toks:
|
|
continue
|
|
if toks[0].startswith("--"):
|
|
i = 0
|
|
while i < len(toks):
|
|
key = toks[i].lstrip("-").replace("-", "_")
|
|
if i + 1 < len(toks) and not toks[i + 1].startswith("--"):
|
|
opts[key] = toks[i + 1]
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
else:
|
|
offset, relpath = toks[0], toks[1]
|
|
parts.append((offset, os.path.join(bdir, relpath)))
|
|
return parts, opts
|
|
|
|
|
|
# ── job streaming over WebSocket ───────────────────────────────
|
|
|
|
def ws_url_for(http_url: str, path: str) -> str:
|
|
u = urllib.parse.urlparse(http_url)
|
|
scheme = "wss" if u.scheme == "https" else "ws"
|
|
return f"{scheme}://{u.netloc}{path}"
|
|
|
|
|
|
def stream_job(ws_url: str, job_id: str) -> int:
|
|
"""Connect to /esp/stream and print output until the job ends."""
|
|
async def _run() -> int:
|
|
async with websockets.connect(ws_url) as ws:
|
|
async for raw in ws:
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
print(raw)
|
|
continue
|
|
t = msg.get("type")
|
|
if t == "line":
|
|
print(msg.get("line", ""))
|
|
elif t == "state":
|
|
print(f"[job {msg.get('job_id')}] {msg.get('kind')} "
|
|
f"state={msg.get('state')} port={msg.get('port')}")
|
|
elif t == "done":
|
|
rc = msg.get("return_code")
|
|
print(f"[done] state={msg.get('state')} "
|
|
f"return_code={rc}")
|
|
return int(rc) if rc is not None else 0
|
|
return 0
|
|
return asyncio.run(_run())
|
|
|
|
|
|
# ── subcommands ────────────────────────────────────────────────
|
|
|
|
def cmd_ports(args):
|
|
r = requests.get(f"{args.gateway}/esp/ports", timeout=10)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
ver = data.get("esptool_version") or "not available"
|
|
print(f"esptool: {ver}")
|
|
ports = data.get("ports", [])
|
|
if not ports:
|
|
print("(no /dev/ttyACM* or /dev/ttyUSB* devices)")
|
|
for p in ports:
|
|
desc = (p.get("product") or p.get("description")
|
|
or p.get("manufacturer") or "")
|
|
vid, pid = p.get("vid"), p.get("pid")
|
|
print(f" {p['device']:<18} {desc:<30} "
|
|
f"vid={vid} pid={pid} sn={p.get('serial_number')}")
|
|
|
|
|
|
def cmd_chip(args):
|
|
params = {"port": args.port, "baud": args.baud}
|
|
if args.chip:
|
|
params["chip"] = args.chip
|
|
r = requests.get(f"{args.gateway}/esp/chip", params=params, timeout=30)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
sys.stdout.write(data.get("output", ""))
|
|
sys.exit(0 if data.get("return_code") == 0 else 1)
|
|
|
|
|
|
def cmd_erase(args):
|
|
payload = {"port": args.port, "baud": args.baud, "before": args.before}
|
|
if args.chip:
|
|
payload["chip"] = args.chip
|
|
r = requests.post(f"{args.gateway}/esp/erase", json=payload, timeout=15)
|
|
r.raise_for_status()
|
|
body = r.json()
|
|
if "error" in body:
|
|
sys.exit(body["error"])
|
|
job_id = body["job_id"]
|
|
print(f"[gateway] erase job {job_id} started on {args.port}")
|
|
rc = stream_job(ws_url_for(args.gateway, "/esp/stream"), job_id)
|
|
sys.exit(0 if rc == 0 else 1)
|
|
|
|
|
|
def cmd_flash(args):
|
|
parts, opts = parse_flash_args(args.bundle)
|
|
|
|
flash_mode = args.flash_mode or opts.get("flash_mode", "dio")
|
|
flash_size = args.flash_size or opts.get("flash_size", "8MB")
|
|
flash_freq = args.flash_freq or opts.get("flash_freq", "80m")
|
|
|
|
for spec in args.file or []:
|
|
if "@" not in spec:
|
|
sys.exit(f"--file expects offset@path, got: {spec}")
|
|
offset, path = spec.split("@", 1)
|
|
parts.append((offset, path))
|
|
|
|
if not parts:
|
|
sys.exit("no files to flash (flash_args empty and no --file given)")
|
|
|
|
data = {
|
|
"port": args.port,
|
|
"baud": str(args.baud),
|
|
"flash_mode": flash_mode,
|
|
"flash_size": flash_size,
|
|
"flash_freq": flash_freq,
|
|
"before": args.before,
|
|
"after": args.after,
|
|
"erase": "1" if args.erase else "0",
|
|
}
|
|
if args.chip:
|
|
data["chip"] = args.chip
|
|
|
|
files = []
|
|
open_handles = []
|
|
try:
|
|
for offset, path in parts:
|
|
if not os.path.isfile(path):
|
|
sys.exit(f"missing flash part: {path}")
|
|
fh = open(path, "rb")
|
|
open_handles.append(fh)
|
|
files.append((offset, (os.path.basename(path), fh,
|
|
"application/octet-stream")))
|
|
|
|
print(f"[upload] {len(parts)} part(s) → "
|
|
f"{args.gateway}/esp/flash ({args.port})")
|
|
for off, p in parts:
|
|
print(f" {off:<10} {os.path.basename(p):<28} "
|
|
f"({os.path.getsize(p)} bytes)")
|
|
print(f"[upload] flash_mode={flash_mode} "
|
|
f"flash_size={flash_size} flash_freq={flash_freq}"
|
|
+ (" + erase_flash" if args.erase else ""))
|
|
|
|
r = requests.post(f"{args.gateway}/esp/flash",
|
|
data=data, files=files, timeout=120)
|
|
r.raise_for_status()
|
|
finally:
|
|
for fh in open_handles:
|
|
fh.close()
|
|
|
|
body = r.json()
|
|
if "error" in body:
|
|
sys.exit(body["error"])
|
|
job_id = body["job_id"]
|
|
print(f"[gateway] write_flash job {job_id} started")
|
|
rc = stream_job(ws_url_for(args.gateway, "/esp/stream"), job_id)
|
|
sys.exit(0 if rc == 0 else 1)
|
|
|
|
|
|
def cmd_monitor(args):
|
|
qs = urllib.parse.urlencode({"port": args.port, "baud": args.baud})
|
|
ws_url = f"{ws_url_for(args.gateway, '/esp/monitor')}?{qs}"
|
|
|
|
stdin_is_tty = sys.stdin.isatty()
|
|
old_termios = None
|
|
if stdin_is_tty:
|
|
try:
|
|
import termios
|
|
import tty
|
|
stdin_fd = sys.stdin.fileno()
|
|
old_termios = termios.tcgetattr(stdin_fd)
|
|
tty.setcbreak(stdin_fd)
|
|
except (ImportError, OSError, ValueError):
|
|
old_termios = None
|
|
|
|
async def _run():
|
|
async with websockets.connect(ws_url) as ws:
|
|
async def _recv():
|
|
async for raw in ws:
|
|
if isinstance(raw, bytes):
|
|
sys.stdout.buffer.write(raw)
|
|
sys.stdout.buffer.flush()
|
|
else:
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
print(raw, file=sys.stderr)
|
|
continue
|
|
t = msg.get("type")
|
|
if t == "opened":
|
|
print(f"[monitor] {msg.get('port')} @ "
|
|
f"{msg.get('baud')} (Ctrl-] to quit)",
|
|
file=sys.stderr)
|
|
elif t == "error":
|
|
print(f"[monitor] error: {msg.get('error')}",
|
|
file=sys.stderr)
|
|
|
|
async def _send():
|
|
if not stdin_is_tty:
|
|
# Drain piped stdin and forward it, then stop.
|
|
loop = asyncio.get_event_loop()
|
|
while True:
|
|
ch = await loop.run_in_executor(None, sys.stdin.read, 1)
|
|
if not ch:
|
|
break
|
|
await ws.send(ch)
|
|
return
|
|
loop = asyncio.get_event_loop()
|
|
while True:
|
|
ch = await loop.run_in_executor(None, sys.stdin.read, 1)
|
|
if not ch:
|
|
break
|
|
if ch == "\x1d": # Ctrl-]
|
|
await ws.close()
|
|
break
|
|
await ws.send(ch)
|
|
|
|
recv_t = asyncio.create_task(_recv())
|
|
send_t = asyncio.create_task(_send())
|
|
try:
|
|
await recv_t
|
|
finally:
|
|
send_t.cancel()
|
|
try:
|
|
await send_t
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
try:
|
|
asyncio.run(_run())
|
|
finally:
|
|
if old_termios is not None:
|
|
import termios
|
|
try:
|
|
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_termios)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def cmd_jobs(args):
|
|
if args.job_id:
|
|
r = requests.get(f"{args.gateway}/esp/jobs/{args.job_id}", timeout=10)
|
|
r.raise_for_status()
|
|
print(json.dumps(r.json(), indent=2))
|
|
return
|
|
r = requests.get(f"{args.gateway}/esp/jobs", timeout=10)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
cur = data.get("current")
|
|
jobs = data.get("jobs", [])
|
|
if not jobs:
|
|
print("(no jobs yet)")
|
|
return
|
|
for j in jobs:
|
|
marker = "*" if j["id"] == cur else " "
|
|
print(f"{marker} {j['id']} {j['kind']:<22} {j['state']:<8} "
|
|
f"{j.get('port', ''):<16} rc={j.get('return_code')}")
|
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────────────
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="esp_client.py",
|
|
description="Flash ESP32 devices via the lab-gateway /esp/* endpoints.",
|
|
)
|
|
p.add_argument(
|
|
"--gateway",
|
|
default=os.environ.get("LAB_GATEWAY", "http://192.168.0.38:8910"),
|
|
help="lab-gateway base URL "
|
|
"(default: $LAB_GATEWAY or http://192.168.0.38:8910)",
|
|
)
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
sub.add_parser("ports", help="list USB serial ports on the gateway host") \
|
|
.set_defaults(func=cmd_ports)
|
|
|
|
s = sub.add_parser("chip", help="run esptool chip_id")
|
|
s.add_argument("--port", default="/dev/ttyACM0")
|
|
s.add_argument("--baud", type=int, default=460800)
|
|
s.add_argument("--chip", default=None)
|
|
s.set_defaults(func=cmd_chip)
|
|
|
|
s = sub.add_parser("erase", help="run esptool erase_flash")
|
|
s.add_argument("--port", default="/dev/ttyACM0")
|
|
s.add_argument("--baud", type=int, default=460800)
|
|
s.add_argument("--chip", default=None)
|
|
s.add_argument("--before", default="usb-reset")
|
|
s.set_defaults(func=cmd_erase)
|
|
|
|
s = sub.add_parser("flash", help="upload a build bundle and run write_flash")
|
|
s.add_argument("--bundle", required=True,
|
|
help="build dir containing flash_args + *.bin")
|
|
s.add_argument("--port", default="/dev/ttyACM0")
|
|
s.add_argument("--baud", type=int, default=460800)
|
|
s.add_argument("--chip", default=None)
|
|
s.add_argument("--before", default="usb-reset")
|
|
s.add_argument("--after", default="hard-reset")
|
|
s.add_argument("--flash-mode", default=None)
|
|
s.add_argument("--flash-size", default=None)
|
|
s.add_argument("--flash-freq", default=None)
|
|
s.add_argument("--erase", action="store_true",
|
|
help="erase_flash before write_flash")
|
|
s.add_argument("--file", action="append", default=[],
|
|
help="extra part as offset@path (may repeat)")
|
|
s.set_defaults(func=cmd_flash)
|
|
|
|
s = sub.add_parser("monitor", help="raw serial monitor (Ctrl-] to quit)")
|
|
s.add_argument("--port", default="/dev/ttyACM0")
|
|
s.add_argument("--baud", type=int, default=115200)
|
|
s.set_defaults(func=cmd_monitor)
|
|
|
|
s = sub.add_parser("jobs", help="list recent jobs, or show one with --job-id")
|
|
s.add_argument("--job-id", default=None)
|
|
s.set_defaults(func=cmd_jobs)
|
|
|
|
return p
|
|
|
|
|
|
def main():
|
|
args = build_parser().parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|