replica-omnisciente/infra/lab-gateway/esp_client.py
Raphael Cautus (Maestro) 39fb44fe0e feat(infra): proxmox IaC, firmware, savearth realm, lab-gateway ESP, CI
- 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>
2026-07-31 14:57:41 +01:00

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()