app
LAN7800 EEPROM Programmer
Public Made by Adomby adom
MPLAB-Connect-parity EEPROM tool for the Microchip LAN7800 USB-3.1 to Gigabit-Ethernet controller (VID 0x0424 / PID 0x7800). Read an adapter's EEPROM to a .bin, edit MAC / VID / PID / bcdDevice / strings / serial and LED/GPIO with a byte-exact preview, and program hardware with verify-after-write. Reads are free; physical writes are gated behind --force-physical-write. Ships an AI-oriented CLI (single source of truth) plus a Hydrogen webapp that shells out to it.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
#!/usr/bin/env python3
"""LAN7800 EEPROM Programmer, Hydrogen webapp (shells out to the CLI).
The web layer holds ZERO EEPROM logic: every operation runs `lan7800prog
--json ...` against a per-session working .bin, so the CLI stays the single
source of truth. Reads/edits/preview/save are free. Physical PROGRAM is
deliberately NOT wired to hardware here: the UI shows the exact gated command
and the escalation requirement instead of ever setting --force-physical-write.
AI-drivable per adom-ui-design: state lives on the server (GET /state), there is
a GET /console log, and every action returns structured JSON.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
HERE = os.path.dirname(os.path.abspath(__file__))
CLI = os.path.join(HERE, "..", "cli", "lan7800prog")
WORK_DIR = tempfile.mkdtemp(prefix="lan7800-webapp-")
WORK_BIN = os.path.join(WORK_DIR, "session.bin")
# Default template for BULK mode when the operator leaves the Template field
# blank. The bulk serial is stamped by an in-place string edit, so the template
# must carry a serial string slot; the built-in default template has blank
# strings and would fail at unit 0. This factory-derived image has ADOM/LAN7800
# strings + a 12-char serial slot that fits the mac-hex serial.
DEFAULT_BULK_TEMPLATE = os.path.join(HERE, "..", "templates",
"lan7800-factory-template.bin")
# Default manifest so the operator can leave the field blank (no long path to
# paste). The CLI Manifest class does NOT create parent dirs, so we ensure the
# manifests/ dir exists here.
DEFAULT_BULK_MANIFEST = os.path.normpath(
os.path.join(HERE, "..", "manifests", "bulk-run.csv"))
try:
os.makedirs(os.path.dirname(DEFAULT_BULK_MANIFEST), exist_ok=True)
except OSError:
pass
CONSOLE: list[str] = []
_BAD_JSON = object() # sentinel: body present but not valid JSON
SOURCE = {"kind": "defaults", "name": None} # defaults | image | device
def log(msg: str) -> None:
line = f"{time.strftime('%H:%M:%S')} {msg}"
CONSOLE.append(line)
del CONSOLE[:-200]
print(line, file=sys.stderr)
def run_cli(*args) -> dict:
"""Run the CLI with --json and return its parsed object."""
cmd = [sys.executable, CLI, "--json", *args]
log("cli " + " ".join(args))
proc = subprocess.run(cmd, capture_output=True, text=True)
out = proc.stdout.strip()
try:
obj = json.loads(out) if out else {"ok": proc.returncode == 0}
except json.JSONDecodeError:
obj = {"ok": False, "error": "internal CLI error"}
if not obj.get("ok") and proc.stderr.strip() and "error" not in obj:
obj["error"] = proc.stderr.strip()
return obj
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json"):
if isinstance(body, (dict, list)):
body = json.dumps(body).encode()
elif isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self):
n = int(self.headers.get("Content-Length", 0) or 0)
if not n:
return {}
raw = self.rfile.read(n)
try:
return json.loads(raw or b"{}")
except json.JSONDecodeError:
return _BAD_JSON
def log_message(self, *a):
pass
# -- routes -----------------------------------------------------------
def do_GET(self):
path = urlparse(self.path).path
if path in ("/", "/index.html"):
return self._send(200, INDEX_HTML, "text/html; charset=utf-8")
if path == "/state":
return self._send(200, self._state())
if path == "/console":
return self._send(200, {"ok": True, "lines": CONSOLE[-100:]})
if path == "/scan":
return self._send(200, run_cli("scan"))
if path == "/bulk-defaults":
return self._send(200, {"ok": True,
"manifest": DEFAULT_BULK_MANIFEST,
"template": os.path.normpath(DEFAULT_BULK_TEMPLATE)})
if path == "/bin":
if not os.path.exists(WORK_BIN):
return self._send(404, {"ok": False, "error": "no session image"})
with open(WORK_BIN, "rb") as fh:
return self._send(200, fh.read(), "application/octet-stream")
return self._send(404, {"ok": False, "error": "not found"})
def do_POST(self):
path = urlparse(self.path).path
if path == "/upload":
return self._upload()
body = self._body_json()
if body is _BAD_JSON:
return self._send(400, {"ok": False, "error": "malformed JSON body"})
if path == "/new":
r = run_cli("new", "--out", WORK_BIN)
SOURCE["kind"], SOURCE["name"] = "defaults", None
return self._send(200, {**r, "state": self._state_obj()})
if path == "/load":
src = body.get("path", "")
if not src or not os.path.exists(src):
return self._send(400, {"ok": False,
"error": f"no such file: {src}"})
shutil.copyfile(src, WORK_BIN)
SOURCE["kind"], SOURCE["name"] = "image", os.path.basename(src)
log(f"loaded base {src}")
return self._send(200, {"ok": True, "state": self._state_obj()})
if path == "/read-connected":
r = run_cli("read-device", "--out", WORK_BIN)
if r.get("ok"):
SOURCE["kind"], SOURCE["name"] = "device", \
(r.get("device", {}) or {}).get("usb_path")
return self._send(200 if r.get("ok") else 502,
{**r, "state": self._state_obj()})
if path == "/read-device":
iface = body.get("iface", "").strip()
if not iface:
return self._send(400, {"ok": False, "error": "iface required"})
r = run_cli("read", "--iface", iface, "--out", WORK_BIN)
if r.get("ok"):
SOURCE["kind"], SOURCE["name"] = "device", iface
return self._send(200 if r.get("ok") else 502,
{**r, "state": self._state_obj()})
if path == "/edit":
r = self._edit(body)
return self._send(200 if r.get("ok") else 400, r)
if path == "/save":
dst = body.get("path", "")
if not dst:
return self._send(400, {"ok": False, "error": "path required"})
if not os.path.exists(WORK_BIN):
return self._send(400, {"ok": False, "error": "no session image"})
os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
shutil.copyfile(WORK_BIN, dst)
log(f"saved to {dst}")
return self._send(200, {"ok": True, "path": dst})
if path == "/verify":
iface = body.get("iface", "").strip()
if not iface:
return self._send(400, {"ok": False, "error": "iface required"})
r = run_cli("verify", "--iface", iface, "--image", WORK_BIN)
return self._send(200 if r.get("ok") else 502, r)
if path == "/program":
return self._send(200, self._program_request())
if path == "/program-poll":
return self._send(200, self._program_poll(body.get("escalation_id", "")))
if path == "/bulk-preview":
return self._send(200, self._bulk_preview(body))
if path == "/bulk-program":
return self._send(200, self._bulk_program_request(body))
if path == "/bulk-program-poll":
return self._send(200, self._bulk_program_poll(body.get("escalation_id", "")))
if path == "/bulk-burn-one":
return self._send(200, self._bulk_burn_one(body))
return self._send(404, {"ok": False, "error": "not found"})
# -- helpers ----------------------------------------------------------
def _upload(self):
n = int(self.headers.get("Content-Length", 0) or 0)
if not n:
return self._send(400, {"ok": False, "error": "empty upload"})
if n > 512:
return self._send(413, {"ok": False,
"error": f"file too large ({n}B); EEPROM is 512B max"})
data = self.rfile.read(n)
with open(WORK_BIN, "wb") as fh:
fh.write(data)
fname = self.headers.get("X-Filename", "upload.bin")
SOURCE["kind"], SOURCE["name"] = "image", fname
log(f"uploaded {fname} ({len(data)}B) via file picker")
resp = {"ok": True, "bytes": len(data), "state": self._state_obj()}
if not data or data[0] != 0xA5:
resp["hint"] = ("loaded, but this is not a signed LAN7800 EEPROM "
"(byte0 is not 0xA5); the device would ignore it")
return self._send(200, resp)
def _edit(self, body) -> dict:
args = ["edit", "--in", WORK_BIN, "--out", WORK_BIN]
for key, flag in (("mac", "--mac"), ("vid", "--vid"), ("pid", "--pid"),
("bcd", "--bcd"), ("serial", "--serial")):
val = body.get(key)
if val not in (None, ""):
args += [flag, str(val)]
for name, val in (body.get("strings") or {}).items():
if val not in (None, ""):
args += ["--set-string", f"{name}={val}"]
for key, flag in (("set_led", "--set-led"), ("led_enable", "--led-enable"),
("set_gpio", "--set-gpio"), ("gpio_wake", "--gpio-wake"),
("set_configflag", "--set-configflag"),
("set_raw", "--set-raw")):
for spec in (body.get(key) or []):
if spec:
args += [flag, str(spec)]
if body.get("led_blink") not in (None, ""):
args += ["--led-blink", str(body["led_blink"])]
if body.get("ensure_signature"):
args += ["--ensure-signature"]
if len(args) <= 4:
return {"ok": False, "error": "no fields to edit"}
r = run_cli(*args)
r["state"] = self._state_obj()
return r
# -- gated program flow (webapp escalates; writes only after PROCEED) ----
def _program_request(self) -> dict:
if not os.path.exists(WORK_BIN):
return {"ok": False, "error": "no image to program"}
info = run_cli("info", "--in", WORK_BIN)
# Never assemble/escalate an unsigned or empty image.
if not info.get("signature_ok"):
return {"ok": False, "error": "refusing to program an UNSIGNED image "
"(byte0 != 0xA5). Click 'Load defaults to program this chip', "
"set a MAC/VID/PID, then Apply before programming."}
if info.get("mac") in (None, "00:00:00:00:00:00"):
return {"ok": False, "error": "refusing to program an all-zero MAC. "
"Set a real MAC (Microchip OUI 00:80:0F:xx:xx:xx) and Apply first."}
d0 = next((x for x in info.get("descriptors", []) if x.get("present")), {})
summ = (f"MAC {info.get('mac')} VID {d0.get('vid')} PID {d0.get('pid')} "
f"sig {info.get('signature')}")
# diff vs the connected device (free libusb read)
v = run_cli("verify", "--image", WORK_BIN)
diff = v.get("diff_count", "?")
q = (f"Human clicked Program on the connected 0424:7800 (libusb, sudo-free). "
f"Image: {summ}. Bytes differing vs current device: {diff}. "
f"Approve this ONE physical EEPROM write (signature written last, "
f"verify-after-write)?")
proc = subprocess.run(
["mesh", "escalate", "--agent", "LAN7800",
"--action", "program-device", "--question", q,
"--risk", "medium", "--default", "hold"],
capture_output=True, text=True)
try:
r = json.loads(proc.stdout.strip() or "{}")
except json.JSONDecodeError:
return {"ok": False, "error": "could not raise escalation",
"detail": proc.stderr.strip()[:200]}
eid = r.get("escalation_id")
log(f"program escalated: {eid}")
return {"ok": True, "pending": True, "escalation_id": eid,
"summary": summ, "diff_count": diff,
"message": "Escalated to CONDUCTOR for approval; waiting for PROCEED."}
def _poll_decision(self, eid: str):
"""Scan CONDUCTOR's thread for a decision on THIS escalation_id.
Returns 'allow' | 'deny' | None (still pending). Shared by the single
program flow and the bulk burn so both match identically."""
thread = os.path.expanduser("~/.agent-mesh/CONDUCTOR_LAN7800.jsonl")
decision = None
try:
with open(thread) as fh:
for line in fh:
try:
m = json.loads(line)
except json.JSONDecodeError:
continue
if m.get("kind") != "decision":
continue
# Match the STRUCTURED escalation_id field. The prior code
# checked `eid in msg` -- but a decision carries the id in the
# `escalation_id` field, NOT the human-facing `msg` text, so
# it never matched and the UI polled forever. Fall back to a
# substring check for older/hand-written decisions.
ref = m.get("escalation_id", "")
msg = m.get("msg", "")
if ref == eid or (eid and eid in msg):
verdict = (m.get("verdict") or msg).upper()
if "PROCEED" in verdict or "ALLOW" in verdict:
decision = "allow"
elif "STOP" in verdict or "DENY" in verdict:
decision = "deny"
except OSError:
pass
return decision
def _program_poll(self, eid: str) -> dict:
if not eid:
return {"ok": False, "error": "escalation_id required"}
decision = self._poll_decision(eid)
if decision is None:
return {"ok": True, "pending": True,
"message": "Still waiting for CONDUCTOR approval."}
if decision == "deny":
log(f"program {eid}: DENIED")
return {"ok": False, "denied": True,
"message": "CONDUCTOR denied the program request."}
# APPROVED -> run the real gated libusb write + verify
log(f"program {eid}: PROCEED -> writing via libusb")
r = run_cli("program", "--image", WORK_BIN, "--force-physical-write")
if r.get("ok"):
SOURCE["kind"], SOURCE["name"] = "device", "programmed"
return {"ok": True, "programmed": True, "result": r,
"state": self._state_obj(),
"message": f"Programmed via {r.get('backend')} "
f"({'verified' if r.get('verified') else 'no-verify'})."}
return {"ok": False, "result": r,
"message": r.get("error") or "program failed", "state": self._state_obj()}
# -- bulk program mode (reuses the CLI `bulk` subcommand; no dup logic) ---
def _bulk_cli_args(self, body):
"""Validate the bulk inputs and build the shared CLI arg list. Returns a
dict on error (so callers can `if isinstance(args, dict): return args`)."""
base = (body.get("base") or "").strip()
if not base:
return {"ok": False, "error": "base MAC is required"}
try:
count = int(body.get("count"))
except (TypeError, ValueError):
return {"ok": False, "error": "count must be an integer"}
if count < 1:
return {"ok": False, "error": "count must be >= 1"}
args = ["--base", base, "--count", str(count)]
step = body.get("step")
if step not in (None, ""):
try:
args += ["--step", str(int(step))]
except (TypeError, ValueError):
return {"ok": False, "error": "step must be an integer"}
if body.get("scheme"):
args += ["--scheme", str(body["scheme"])]
if body.get("local_admin"):
args += ["--local-admin"]
tmpl = (body.get("template") or "").strip()
if not tmpl and os.path.exists(DEFAULT_BULK_TEMPLATE):
tmpl = DEFAULT_BULK_TEMPLATE # blank -> factory template (has a serial slot)
if tmpl:
args += ["--template", tmpl]
return args
def _effective_template(self, body) -> str:
tmpl = (body.get("template") or "").strip()
if not tmpl and os.path.exists(DEFAULT_BULK_TEMPLATE):
return os.path.normpath(DEFAULT_BULK_TEMPLATE)
return tmpl or "(built-in default template)"
def _bulk_preview(self, body) -> dict:
"""DRY-RUN preview: full per-unit MAC/serial table, no device, no writes.
Uses a THROWAWAY temp manifest so a preview never pollutes a real-burn
manifest."""
import tempfile
args = self._bulk_cli_args(body)
if isinstance(args, dict):
return args
fd, tmp = tempfile.mkstemp(suffix=".csv", prefix="bulk-preview-")
os.close(fd)
try:
r = run_cli("bulk", *args, "--dry-run", "--manifest", tmp)
finally:
for p in (tmp, os.path.splitext(tmp)[0] + ".json"):
try:
os.remove(p)
except OSError:
pass
if isinstance(r, dict):
r["template_used"] = self._effective_template(body)
return r
def _bulk_program_request(self, body) -> dict:
"""Escalate ONCE for a session-scoped allow before any unit is written."""
args = self._bulk_cli_args(body)
if isinstance(args, dict):
return args
scheme = body.get("scheme") or "mac-hex"
la = ", locally-administered 02:" if body.get("local_admin") else ""
q = (f"BULK burn: program {body.get('count')} LAN7800 unit(s) from base "
f"MAC {body.get('base')} (serial={scheme}{la}). SESSION-scoped allow "
f"for this run: per-unit insert -> write (signature last) -> verify "
f"-> manifest row. Approve the run?")
proc = subprocess.run(
["mesh", "escalate", "--agent", "LAN7800", "--action",
"program-device", "--question", q, "--risk", "medium",
"--default", "hold"], capture_output=True, text=True)
try:
r = json.loads(proc.stdout.strip() or "{}")
except json.JSONDecodeError:
return {"ok": False, "error": "could not raise escalation",
"detail": proc.stderr.strip()[:200]}
eid = r.get("escalation_id")
log(f"bulk escalated: {eid}")
return {"ok": True, "pending": True, "escalation_id": eid,
"message": "Bulk burn escalated to CONDUCTOR; waiting for a "
"session-scoped PROCEED."}
def _bulk_program_poll(self, eid: str) -> dict:
if not eid:
return {"ok": False, "error": "escalation_id required"}
d = self._poll_decision(eid)
if d is None:
return {"ok": True, "pending": True,
"message": "Waiting for CONDUCTOR session-scoped approval."}
if d == "deny":
return {"ok": False, "denied": True,
"message": "CONDUCTOR denied the bulk burn."}
return {"ok": True, "allow": True, "message": "Approved; burn may proceed."}
def _bulk_burn_one(self, body) -> dict:
"""Program the NEXT un-burned unit on the connected device (one per call;
the operator inserts each unit between calls). Requires a persistent
--manifest so the run is resumable and MAC reuse is refused."""
manifest = (body.get("manifest") or "").strip() or DEFAULT_BULK_MANIFEST
# The CLI Manifest class does not create parent dirs; ensure it exists.
try:
os.makedirs(os.path.dirname(os.path.abspath(manifest)), exist_ok=True)
except OSError as e:
return {"ok": False, "error": f"cannot create manifest dir: {e}"}
args = self._bulk_cli_args(body)
if isinstance(args, dict):
return args
r = run_cli("bulk", *args, "--manifest", manifest, "--force-physical-write")
if isinstance(r, dict):
r["template_used"] = self._effective_template(body)
r["manifest_used"] = os.path.normpath(manifest)
return r
def _state_obj(self) -> dict:
if not os.path.exists(WORK_BIN):
return {"loaded": False, "source_kind": "defaults"}
info = run_cli("info", "--in", WORK_BIN)
info["loaded"] = True
info["source_kind"] = SOURCE["kind"]
info["source_name"] = SOURCE["name"]
return info
def _state(self) -> dict:
return {"ok": True, "state": self._state_obj()}
INDEX_HTML = r"""<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>USB3.0 to Ethernet Molecule Programmer</title>
<style>
:root{--bg:#0e1116;--panel:#161b22;--edge:#1c2128;--fg:#e6edf3;--mut:#8b949e;
--acc:#00b8b1;--acc-d:#00938d;--ok:#3fb950;--warn:#d29922;--bad:#f85149}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}
header{height:44px;display:flex;align-items:center;gap:10px;padding:0 16px;
background:var(--panel);border-bottom:1px solid var(--edge);position:sticky;top:0;z-index:5}
header .dot{width:10px;height:10px;border-radius:50%;background:var(--acc)}
header h1{font-size:15px;margin:0;font-weight:600}
header .sig{margin-left:auto;font-size:12px;color:var(--mut)}
main{max-width:960px;margin:0 auto;padding:16px;display:grid;gap:16px;
grid-template-columns:1fr 1fr}
.panel{background:var(--panel);border:1px solid var(--edge);border-radius:10px;padding:14px}
.panel.full{grid-column:1/-1}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);margin:0 0 10px}
h2 .warn{color:var(--warn);text-transform:none;letter-spacing:0}
label{display:block;font-size:12px;color:var(--mut);margin:8px 0 3px}
input,select{width:100%;background:#0d1117;border:1px solid var(--edge);color:var(--fg);
border-radius:6px;padding:7px 9px;font:13px monospace}
.row{display:flex;gap:8px} .row>*{flex:1}
button{background:var(--acc);color:#08110f;border:0;border-radius:6px;padding:8px 12px;
font-weight:600;cursor:pointer} button:hover{background:var(--acc-d)}
button.ghost{background:#21262d;color:var(--fg)} button.ghost:hover{background:#2b323c}
button:disabled{opacity:.45;cursor:not-allowed}
button.danger{background:var(--bad);color:#fff}
.kv{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--edge);padding:4px 0}
.kv b{font-weight:600;font-family:monospace}
.pill{font-size:11px;padding:2px 7px;border-radius:20px;background:#2b323c}
.pill.ok{background:rgba(63,185,80,.15);color:var(--ok)}
.pill.bad{background:rgba(248,81,73,.15);color:var(--bad)}
.note{font-size:12px;color:var(--warn);margin-top:8px}
.mut{color:var(--mut)}
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;
display:flex;flex-direction:column;align-items:center;gap:8px;z-index:20}
.t{background:#21262d;border:1px solid var(--edge);border-left:3px solid var(--acc);
padding:10px 14px;border-radius:6px;max-width:360px;font-size:13px;animation:in .2s}
.t.ok{border-left-color:var(--ok)}
.t.bad{border-left-color:var(--bad);background:rgba(248,81,73,.14);
border-color:rgba(248,81,73,.5);color:#ffd7d3}
.t.bad::before{content:"Error: ";font-weight:700;color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
@media(pointer:coarse){input[type=checkbox]{min-width:22px;min-height:22px}
.panel label{padding:5px 0;min-height:30px}}
@media(max-width:720px){main{grid-template-columns:1fr} #ledgpio{grid-template-columns:1fr !important}}
@media(max-width:430px){header{height:auto;min-height:44px;padding:6px 12px}
header h1{font-size:13px;line-height:1.15}}
</style></head><body>
<header><span class="dot"></span><h1>USB3.0 to Ethernet Molecule Programmer</h1>
<span class="pill" id="srcind" style="margin-left:auto"
title="What the form is currently showing">Defaults</span>
<span class="sig" id="sig" style="margin-left:12px">no image</span></header>
<main>
<div id="blankBanner" class="panel full" role="alert"
style="display:none;border-color:var(--bad);background:rgba(248,81,73,.09)"></div>
<div id="overlapBanner" class="panel full" role="alert"
style="display:none;border-color:var(--warn);background:rgba(210,153,34,.09)"></div>
<section class="panel">
<h2>Source</h2>
<button class="ghost" style="width:100%" onclick="scan()"
title="Enumerate attached LAN7800 adapters (USB 0424:7800). Reads only.">Scan for devices</button>
<div id="scanList"></div>
<label for="loadFile">Base image (.bin)</label>
<div class="row"><input id="loadFile" type="file" accept=".bin,application/octet-stream"
aria-label="Choose a .bin EEPROM image from disk"
title="Pick a .bin file (max 512 bytes); it uploads and previews without touching hardware"
onchange="uploadFile()" style="padding:5px">
<input id="loadPath" placeholder="or type a server path" style="flex:1.2"
aria-label="Server-side path to a .bin image"
title="Absolute path to a .bin already on the server">
<button class="ghost" style="flex:0 0 auto" onclick="loadBase()"
title="Load the .bin at the typed server path">Load path</button></div>
<button style="width:100%;margin-top:8px" onclick="readConnected()"
title="Read the EEPROM from the currently connected LAN7800 (auto: netdev via ethtool, else libusb). Free, no write.">Read connected device</button>
<label for="iface">Or read a specific interface (free)</label>
<div class="row"><input id="iface" placeholder="e.g. enx00800f780000"
aria-label="Network interface of the LAN7800"
title="The lan78xx-bound network interface to read the EEPROM from">
<button class="ghost" style="flex:0 0 auto" onclick="readDev()"
title="Read the device EEPROM via this interface (free, no write)">Read</button></div>
<div class="row" style="margin-top:10px">
<button class="ghost" onclick="newImg()"
title="Create a fresh 512-byte image with the 0xA5 signature set">New blank (signed)</button>
<a href="/bin" download="lan7800.bin" style="flex:1"><button class="ghost" style="width:100%"
title="Download the current session image as a .bin">Download .bin</button></a>
</div>
</section>
<section class="panel">
<h2>Edit fields</h2>
<label for="mac">MAC (Microchip OUI 00:80:0F)</label>
<input id="mac" placeholder="00:80:0F:xx:xx:xx" aria-label="MAC address"
title="Six hex octets, colon or hyphen separated">
<div class="row">
<div><label for="vid">VID</label><input id="vid" placeholder="0x0424"
aria-label="USB Vendor ID" title="USB Vendor ID, decimal or 0x-hex (LAN7800 is 0x0424)"></div>
<div><label for="pid">PID</label><input id="pid" placeholder="0x7800"
aria-label="USB Product ID" title="USB Product ID, decimal or 0x-hex (LAN7800 is 0x7800)"></div>
<div><label for="bcd">bcdDevice</label><input id="bcd" placeholder="0x0300"
aria-label="bcdDevice (device release number)" title="bcdDevice / DID, 16-bit"></div></div>
<label for="serial">Serial (in-place, fits existing slot)</label>
<input id="serial" placeholder="SN..." aria-label="Serial number string"
title="Overwrites the existing serial string in place; must fit the current slot">
<label for="product">Product string (in-place)</label>
<input id="product" placeholder="LAN7800 ..." aria-label="Product descriptor string"
title="Overwrites the existing product string in place">
<label for="manufacturer">Manufacturer string (in-place)</label>
<input id="manufacturer" aria-label="Manufacturer descriptor string"
title="Overwrites the existing manufacturer string in place">
<div class="row">
<div><label for="configuration">Configuration string</label>
<input id="configuration" aria-label="Configuration descriptor string" title="In-place"></div>
<div><label for="interface">Interface string</label>
<input id="interface" aria-label="Interface descriptor string" title="In-place"></div></div>
<label>Config Flags 0..3 (32-bit hex, from the loaded image)</label>
<div class="row">
<input id="cf0" aria-label="Config Flag 0" title="Config Flags word 0 (0x13)" placeholder="0x00000000">
<input id="cf1" aria-label="Config Flag 1" title="Config Flags word 1 (0x17)" placeholder="0x00000000">
<input id="cf2" aria-label="Config Flag 2" title="Config Flags word 2 (0x1B)" placeholder="0x00000000">
<input id="cf3" aria-label="Config Flag 3" title="Config Flags word 3 (0x1F)" placeholder="0x00000000"></div>
<div class="row" style="margin-top:12px">
<button onclick="applyEdit()" title="Apply these fields and refresh the preview">Apply and preview</button>
<button class="ghost" onclick="refresh()" title="Reload state from the server">Reload state</button></div>
<div class="note">VID/PID/strings need a base image with descriptor blocks. Signature auto-set on apply.</div>
</section>
<section class="panel full">
<h2>LED / GPIO <span class="warn">(bit layout: verify vs datasheet sec 15)</span></h2>
<div id="ledgpio" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div><b style="font-size:12px">LEDs</b><div id="ledRows"></div>
<label for="blink">Blink rate</label>
<select id="blink" aria-label="LED blink rate" title="Global LED blink rate">
<option value="">(unchanged)</option><option value="0">2.5Hz</option>
<option value="1">5Hz</option><option value="2">10Hz</option><option value="3">20Hz</option></select></div>
<div><b style="font-size:12px">GPIO</b>
<label for="gpioPin">Pin</label>
<select id="gpioPin" aria-label="GPIO pin index" title="Which GPIO pin to edit (0..7)"></select>
<label for="gpioDir">Direction</label>
<select id="gpioDir" aria-label="GPIO direction" title="Input or output">
<option value="">(unchanged)</option><option>in</option><option>out</option></select>
<label for="gpioDrive">Drive</label>
<select id="gpioDrive" aria-label="GPIO drive type" title="Output driver type">
<option value="">(unchanged)</option><option>push-pull</option><option>open-drain</option><option>default</option></select>
<label><input type="checkbox" id="gpioOut" style="width:auto" aria-label="GPIO output high"
title="Output level when direction is output"> output high</label>
<label><input type="checkbox" id="gpioWake" style="width:auto" aria-label="GPIO wake enable"
title="Enable wake-on-GPIO for this pin"> wake enable</label>
<label><input type="checkbox" id="gpioPol" style="width:auto" aria-label="GPIO wake active-high"
title="Wake polarity: checked means active-high"> wake active-high</label>
</div>
</div>
<button style="margin-top:12px" onclick="applyLedGpio()"
title="Apply LED and GPIO settings and refresh the preview">Apply LED/GPIO and preview</button>
<div class="note">Offsets are authoritative (LED 0x0B to 0x0D and 0x58 to 0x59; GPIO 0x07, 0x0E,
0x48 to 0x4F); per-bit encoding is best-effort until confirmed against a real dump. A write that
would land inside a USB descriptor block is blocked to avoid corruption.</div>
</section>
<section class="panel full">
<h2>Live preview (exact .bin, no hardware)</h2>
<div id="preview"><span class="mut">Load or read an image to begin.</span></div>
<div class="row" style="margin-top:12px;max-width:520px">
<input id="savePath" placeholder="/path/to/output.bin" aria-label="Path to save the .bin to"
title="Server-side path to write the current image">
<button style="flex:0 0 auto" onclick="save()" title="Save the current image to the path">Save .bin</button></div>
</section>
<section class="panel full">
<h2>Hardware</h2>
<div class="row" style="max-width:520px">
<input id="vIface" placeholder="interface for verify (free)" aria-label="Interface for verify"
title="Interface to compare the device EEPROM against the current image">
<button class="ghost" style="flex:0 0 auto" onclick="verify()"
title="Read the device and compare it to the current image (free)">Verify device vs image</button></div>
<button class="danger" style="margin-top:12px" onclick="program()"
title="Programming is gated; this returns the exact command to run under escalation">Program to device...</button>
<div class="note">Warning: programming writes to a physical adapter and is gated.
This UI never writes hardware directly; it returns the exact command, which an
operator runs only after a mesh escalate PROCEED.</div>
</section>
<section class="panel full">
<h2>Bulk Program mode</h2>
<div class="note">Program many adapters with an auto-incrementing identity.
<b>Preview</b> is safe (no device). <b>Program All</b> is gated: it escalates
ONCE to CONDUCTOR, then walks units (insert → write → verify →
manifest).</div>
<div class="row" style="flex-wrap:wrap;gap:8px;max-width:860px;margin-top:8px">
<input id="bkBase" placeholder="base MAC e.g. 00:80:0F:78:00:00" aria-label="Base MAC"
title="Starting MAC; the OUI is not hardcoded" style="flex:1 1 220px">
<input id="bkCount" type="number" min="1" value="10" aria-label="Count"
title="How many units to program" style="flex:0 0 90px">
<input id="bkStep" type="number" value="1" aria-label="Step"
title="MAC increment per unit (default 1)" style="flex:0 0 84px">
<select id="bkScheme" aria-label="Serial scheme" title="Serial numbering scheme"
style="flex:0 0 150px"><option value="mac-hex">serial = MAC hex</option></select>
<label style="flex:0 0 auto;display:flex;align-items:center;gap:6px;margin:0"
title="Force the locally-administered 02: bit instead of using the base OUI">
<input type="checkbox" id="bkLocal" style="width:auto"> local-admin (02:)</label>
<input id="bkTemplate" placeholder="template .bin (blank = factory ADOM/LAN7800)" aria-label="Template"
title="Base image whose serial slot fits the 12-char MAC-hex serial; blank uses the built-in factory template" style="flex:1 1 200px">
<input id="bkManifest" placeholder="blank = default manifests/bulk-run.csv" aria-label="Manifest"
title="Resumable run record; a .json is written alongside. Leave blank to use the default." style="flex:1 1 200px">
</div>
<div class="row" style="margin-top:10px;gap:8px">
<button class="ghost" style="flex:0 0 auto" onclick="bulkPreview()"
title="Compute + show the full N-unit sequence; no device touched">Preview (dry-run)</button>
<button id="bkGo" class="danger" style="flex:0 0 auto" onclick="bulkProgramAll()"
title="Gated: escalate once, program the connected unit as unit 0, then one unit per Next click">Program All (one-at-a-time)…</button>
<button id="bkNext" style="flex:0 0 auto;display:none" onclick="bulkNextUnit()"
title="Detect the newly-inserted blank adapter and program it as the next unit">Next unit →</button>
</div>
<div id="bulkResult" style="margin-top:12px;overflow-x:auto"></div>
<div id="bulkProgress" class="mut" style="margin-top:8px"></div>
</section>
</main>
<div id="toast" aria-live="polite"></div>
<script>
const $=id=>document.getElementById(id);
function toast(msg,kind){const d=document.createElement('div');d.className='t '+(kind||'');
d.textContent=msg;$('toast').appendChild(d);setTimeout(()=>d.remove(),4600);}
async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',
headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
return {status:r.status,data:await r.json().catch(()=>({}))};}
function render(state){
const ob=$('overlapBanner');
if(ob){const ov=state&&state.regs&&state.regs.overlaps;
if(ov&&ov.length){ob.style.display='block';
ob.innerHTML='<b style="color:var(--warn)">Overlap warning:</b> '+state.regs.overlap_warning
+' To change those bytes anyway, Save and use --set-raw in the CLI.';
}else{ob.style.display='none';ob.innerHTML='';}}
if(!state||!state.loaded){$('preview').innerHTML='<span class="mut">No image.</span>';
$('sig').textContent='no image';return;}
// state indicator: Defaults / Loaded image <name> / Live device read
const blank = state.loaded && state.signature_ok===false;
const si=$('srcind');
if(si){const k=state.source_kind;
si.textContent = k==='image' ? ('Loaded image '+(state.source_name||'')+(blank?' (blank)':''))
: k==='device' ? ('Live device read'+(blank?' (blank)':''))
: 'Defaults';
si.className='pill '+(blank?'bad':k==='device'?'ok':k==='image'?'':'');}
const bb=$('blankBanner');
if(bb){if(blank){bb.style.display='block';
bb.innerHTML='<b style="color:var(--bad)">EEPROM is BLANK (unprogrammed).</b> '
+'byte0 is not 0xA5, so the device ignores the EEPROM and runs on OTP / default '
+'(e.g. MAC 00:80:0F:78:00:00). '
+'<button onclick="loadDefaultsToProgram()" style="margin-left:8px">Load defaults to program this chip</button>';
}else{bb.style.display='none';bb.innerHTML='';}}
const ok=state.signature_ok;
$('sig').innerHTML='sig '+state.signature+' <span class="pill '+(ok?'ok':'bad')+'">'
+(ok?'valid':'INVALID')+'</span> '+state.size+'B';
let h='<div class="kv"><span>MAC</span><b>'+state.mac+'</b></div>';
(state.descriptors||[]).forEach(d=>{if(d.present)
h+='<div class="kv"><span>'+d.kind.toUpperCase()+' VID/PID/bcd</span><b>'
+d.vid+' / '+d.pid+' / '+d.bcd_device+'</b></div>';});
(state.strings||[]).forEach(s=>{if(s.present)
h+='<div class="kv"><span>str['+s.name+']</span><b>'+s.value+'</b></div>';});
if(state.lan7800_identity===false)
h+='<div class="note">No block advertises 0x0424/0x7800; confirm this is a LAN7800 image.</div>';
$('preview').innerHTML=h;}
const LED_MODES=["Link/Act","Link1000/Act","Link100/Act","Link10/Act","Link100+1000/Act",
"Link10+1000/Act","Link10+100/Act","reserved","Duplex/Collision","Collision","Activity",
"reserved","AutoNeg Fault","Serial Mode","Force Off","Force On"];
let LAST_STATE=null;
const setv=(id,v)=>{const el=$(id);if(el&&document.activeElement!==el)el.value=(v==null?'':v);};
function buildLedGpio(state){
const lr=$('ledRows');
if(!lr.dataset.built){
let h='';for(let i=0;i<4;i++){
const opts=LED_MODES.map((m,ix)=>'<option value="'+ix+'">'+ix+' '+m+'</option>').join('');
h+='<div class="row" style="align-items:center;margin:3px 0"><label for="ledMode'+i+'" style="margin:0;flex:0 0 44px">LED'+i+'</label>'
+'<input type="checkbox" id="ledEn'+i+'" style="width:auto;flex:0 0 auto" aria-label="LED'+i+' enable" title="Enable LED'+i+'">'
+'<select id="ledMode'+i+'" aria-label="LED'+i+' mode" title="LED'+i+' function (one of 16 modes)">'+opts+'</select></div>';}
lr.innerHTML=h;lr.dataset.built='1';
$('gpioPin').innerHTML=[0,1,2,3,4,5,6,7].map(i=>'<option>'+i+'</option>').join('');
$('gpioPin').addEventListener('change',()=>populateGpio(LAST_STATE));}
}
function populateGpio(state){
const g=state&&state.regs&&state.regs.gpios;if(!g)return;
const p=parseInt($('gpioPin').value||'0',10);const pin=g[p];if(!pin)return;
const s=(id,v)=>{const el=$(id);if(el&&document.activeElement!==el)el.value=v;};
s('gpioDir',pin.direction);s('gpioDrive',pin.drive==='default'?'':pin.drive);
if(document.activeElement!==$('gpioOut'))$('gpioOut').checked=!!pin.output_value;
if(document.activeElement!==$('gpioWake'))$('gpioWake').checked=!!pin.wake_enabled;
if(document.activeElement!==$('gpioPol'))$('gpioPol').checked=!!pin.wake_active_high;
}
// Populate EVERY editable field from the loaded/read image (hard requirement).
function populateFields(state){
if(!state||!state.loaded)return;
setv('mac',state.mac);
const d0=(state.descriptors||[]).find(d=>d.present)||{};
setv('vid',d0.vid);setv('pid',d0.pid);setv('bcd',d0.bcd_device);
const gs=n=>{const s=(state.strings||[]).find(x=>x.name===n);return s&&s.present?s.value:'';};
['manufacturer','product','serial','configuration','interface'].forEach(n=>setv(n,gs(n)));
(state.config_flags||[]).forEach((v,i)=>setv('cf'+i,v));
buildLedGpio(state);
(state.regs&&state.regs.leds||[]).forEach((l,i)=>{
if(document.activeElement!==$('ledEn'+i))$('ledEn'+i).checked=l.enabled;
setv('ledMode'+i,l.mode);});
if(state.regs&&state.regs.led_blink)setv('blink',String(state.regs.led_blink.code));
populateGpio(state);
LAST_STATE=state;
}
async function scan(){toast('Scanning USB');const {data}=await api('/scan');
const el=$('scanList');
if(!data.ok||!data.devices||!data.devices.length){
el.innerHTML='<div class="note">No LAN7800 (0x0424/0x7800) adapters found.</div>';return;}
let h='';data.devices.forEach(d=>{
const badge=d.eeprom==='signed'?'<span class="pill ok">signed</span>':
d.eeprom==='blank'?'<span class="pill bad">blank</span>':
'<span class="pill">'+d.eeprom+'</span>';
const pick=d.iface?'<button class="ghost" style="flex:0 0 auto" onclick="pick(\''+d.iface+'\')" title="Target this adapter">Use</button>':
'<span class="mut" style="font-size:12px">no netdev bound</span>';
h+='<div class="kv"><span>usb '+d.usb_path+', '+(d.iface||'(none)')+', MAC '
+(d.mac||d.usb_serial||'?')+' '+badge+'</span>'+pick+'</div>';});
el.innerHTML=h;toast(data.devices.length+' adapter(s)','ok');}
function pick(iface){$('iface').value=iface;$('vIface').value=iface;toast('Selected '+iface,'ok');}
async function uploadFile(){const f=$('loadFile').files[0];if(!f)return;
const buf=await f.arrayBuffer();
const r=await fetch('/upload',{method:'POST',headers:{'Content-Type':'application/octet-stream',
'X-Filename':f.name},body:buf});
const data=await r.json().catch(()=>({}));
if(data.ok){toast('Loaded '+f.name+' ('+f.size+'B)'+(data.hint?': '+data.hint:''),data.hint?'':'ok');}
else{toast(data.error||'upload failed','bad');}
render(data.state);populateFields(data.state);}
async function loadBase(){const {data}=await api('/load',{path:$('loadPath').value});
data.ok?toast('Base loaded','ok'):toast(data.error,'bad');render(data.state);populateFields(data.state);}
async function readDev(){toast('Reading device');const {data}=await api('/read-device',{iface:$('iface').value});
data.ok?toast('Read OK','ok'):toast(data.error||'read failed','bad');render(data.state);populateFields(data.state);}
async function readConnected(){toast('Reading connected device');const {data}=await api('/read-connected',{});
if(data.ok){toast('Read connected device via '+(data.backend||'device'),'ok');}
else{const errs=(data.errors||[]).join(' | ')||data.error||'read failed';
toast(errs,'bad');
if(data.udev_install_commands){toast('Needs a one-time udev rule; see console for the exact commands','bad');
data.udev_install_commands.forEach(c=>console.log('udev:',c));}}
render(data.state);populateFields(data.state);}
async function newImg(){const {data}=await api('/new',{});toast('Default template loaded','ok');render(data.state);populateFields(data.state);}
async function loadDefaultsToProgram(){const {data}=await api('/new',{});
toast('Loaded signed defaults - edit the fields, then Program to write this blank chip','ok');
render(data.state);populateFields(data.state);}
async function applyEdit(){
const strings={};['manufacturer','product','serial','configuration','interface']
.forEach(n=>{const v=$(n).value;if(v!=='')strings[n]=v;});
const b={mac:$('mac').value,vid:$('vid').value,pid:$('pid').value,
bcd:$('bcd').value,ensure_signature:true,strings,set_configflag:[]};
for(let i=0;i<4;i++){const v=$('cf'+i).value;if(v!=='')b.set_configflag.push(i+'='+v);}
const {data}=await api('/edit',b);
data.ok?toast('Applied: '+(data.changes||[]).join(', '),'ok'):toast(data.error,'bad');
render(data.state);LAST_STATE=data.state;}
async function applyLedGpio(){
const b={set_led:[],led_enable:[],set_gpio:[],gpio_wake:[],ensure_signature:true};
for(let i=0;i<4;i++){b.set_led.push(i+'='+$('ledMode'+i).value);
b.led_enable.push(i+'='+($('ledEn'+i).checked?1:0));}
if($('blink').value!=='')b.led_blink=$('blink').value;
const p=$('gpioPin').value,dir=$('gpioDir').value,drv=$('gpioDrive').value;
b.set_gpio.push(p+'='+dir+','+drv+','+($('gpioOut').checked?1:0));
b.gpio_wake.push(p+'='+($('gpioWake').checked?1:0)+','+($('gpioPol').checked?1:0));
const {data}=await api('/edit',b);
data.ok?toast('LED/GPIO applied','ok'):toast(data.error,'bad');render(data.state);LAST_STATE=data.state;}
async function save(){const {data}=await api('/save',{path:$('savePath').value});
data.ok?toast('Saved '+data.path,'ok'):toast(data.error,'bad');}
async function verify(){toast('Verifying');const {data}=await api('/verify',{iface:$('vIface').value});
data.ok?toast('Device matches image','ok'):toast((data.error||'mismatch'),'bad');}
async function program(){
const {data}=await api('/program',{});
if(!data.ok){toast(data.error||'could not raise escalation','bad');return;}
toast('Program escalated to CONDUCTOR ('+data.diff_count+' bytes differ vs device). Waiting for PROCEED...','');
const eid=data.escalation_id; let n=0;
const iv=setInterval(async()=>{
n++;const {data:p}=await api('/program-poll',{escalation_id:eid});
if(p.pending){if(n>150){clearInterval(iv);toast('Approval timed out; click Program to retry','bad');}return;}
clearInterval(iv);
if(p.programmed){toast(p.message,'ok');render(p.state);populateFields(p.state);}
else if(p.denied){toast(p.message,'bad');}
else{toast(p.message||'program failed','bad');if(p.state)render(p.state);}
},2000);
}
// populate=true only on load events + first paint; the 5s interval refreshes the
// preview/indicator WITHOUT clobbering fields the user is mid-editing.
async function refresh(populate){const {data}=await api('/state');
render(data.state);LAST_STATE=data.state;if(populate)populateFields(data.state);}
// ---- Bulk Program mode ----
function bulkBody(){
const base=$('bkBase').value.trim();
if(!base){toast('Base MAC required','bad');return null;}
const count=parseInt($('bkCount').value||'0',10);
if(!(count>=1)){toast('Count must be >= 1','bad');return null;}
return {base,count,step:parseInt($('bkStep').value||'1',10),
scheme:$('bkScheme').value,local_admin:$('bkLocal').checked,
template:$('bkTemplate').value.trim(),manifest:$('bkManifest').value.trim()};
}
function renderBulkTable(planned){
if(!planned||!planned.length){$('bulkResult').innerHTML='<span class="mut">Nothing to preview (manifest may already be complete).</span>';return;}
let h='<table style="border-collapse:collapse;min-width:420px"><thead><tr>'
+'<th style="text-align:left;padding:3px 10px;border-bottom:1px solid #333">#</th>'
+'<th style="text-align:left;padding:3px 10px;border-bottom:1px solid #333">MAC</th>'
+'<th style="text-align:left;padding:3px 10px;border-bottom:1px solid #333">serial</th></tr></thead><tbody>';
planned.forEach(u=>{h+='<tr><td style="padding:3px 10px">'+u.index+'</td>'
+'<td style="padding:3px 10px"><b>'+u.mac+'</b></td>'
+'<td style="padding:3px 10px">'+u.serial+'</td></tr>';});
$('bulkResult').innerHTML=h+'</tbody></table>';
}
async function bulkPreview(){
const b=bulkBody(); if(!b)return;
toast('Computing preview (no device)...','');
const {data}=await api('/bulk-preview',b);
if(!data.ok){toast(data.error||'preview failed','bad');$('bulkResult').innerHTML='';return;}
renderBulkTable(data.planned);
if(data.template_used)$('bulkProgress').textContent='Template: '+data.template_used;
toast('Preview: '+data.planned.length+' unit(s); no device touched','ok');
}
// One-at-a-time state machine: Program All burns unit 0 (the connected unit),
// then each Next-unit click detects the freshly-inserted adapter, guards against
// re-burning the same one, programs it, verifies, and prompts for the swap.
let BULK=null;
async function bulkProgramAll(){
const b=bulkBody(); if(!b)return;
// Manifest may be blank -> the server uses the default manifests/bulk-run.csv.
const {data:pv}=await api('/bulk-preview',b);
if(!pv.ok){toast(pv.error||'preview failed','bad');return;}
renderBulkTable(pv.planned);
const total=pv.planned.length;
if(!total){toast('Nothing to burn (manifest already complete)','');return;}
if(!confirm('Program '+total+' unit(s) for REAL, ONE AT A TIME?\n'
+'Unit 0 is the adapter plugged in NOW. After each unit you unplug it, insert the next blank one, and click "Next unit".'))return;
const {data:esc}=await api('/bulk-program',b);
if(!esc.ok){toast(esc.error||'escalation failed','bad');return;}
toast('Escalated; waiting for CONDUCTOR session-scoped PROCEED...','');
$('bulkProgress').textContent='Waiting for CONDUCTOR approval...';
const eid=esc.escalation_id; let n=0;
const approved=await new Promise(res=>{const iv=setInterval(async()=>{n++;
const {data:p}=await api('/bulk-program-poll',{escalation_id:eid});
if(p.pending){if(n>150){clearInterval(iv);res(false);}return;}
clearInterval(iv);res(!!p.allow);},2000);});
if(!approved){toast('Not approved / timed out','bad');$('bulkProgress').textContent='';return;}
toast('Approved. Programming unit 0 (the connected adapter)...','ok');
BULK={b,total,done:0,planned:pv.planned};
$('bkGo').disabled=true;
await bulkBurnStep();
}
async function bulkBurnStep(){
if(!BULK)return;
$('bkNext').style.display='none';
const {data:r}=await api('/bulk-burn-one',BULK.b);
if(r.same_unit){ // not-swapped guard fired -> let barrett swap
toast('Looks like the same unit - please swap it','bad');
$('bulkProgress').innerHTML='<b style="color:var(--warn)">Swap needed:</b> '
+(r.error||'insert the next blank adapter')+' Then click <b>Next unit</b>.';
$('bkNext').style.display='inline-block';return;
}
const bu=(r.burned&&r.burned[0])||{};
if(!r.ok||bu.verified===false){
toast('Unit FAILED: '+(r.error||r.message||'verify failed'),'bad');
$('bulkProgress').innerHTML='<b style="color:var(--bad)">Stopped:</b> unit failed ('
+(r.error||'verify failed')+'). Fix, then click <b>Next unit</b> to retry.';
$('bkNext').style.display='inline-block';return;
}
BULK.done++;
if(r.manifest_used)BULK.manifestUsed=r.manifest_used;
const done=BULK.done,total=BULK.total;
toast('Unit '+done+'/'+total+' done: '+bu.mac+' verified','ok');
if(done>=total){
$('bulkProgress').innerHTML='<b style="color:var(--ok)">All '+total+' units done.</b> Manifest: '+(BULK.manifestUsed||BULK.b.manifest||'(default)');
$('bkNext').style.display='none';$('bkGo').disabled=false;BULK=null;return;
}
const next=BULK.planned[done];
$('bulkProgress').innerHTML='<b style="color:var(--ok)">Unit '+done+'/'+total+' done ✓</b> (MAC '+bu.mac+'). '
+'Unplug it, insert the next BLANK adapter, then click <b>Next unit</b>'
+(next?(' (next: '+next.mac+')'):'')+'.';
$('bkNext').style.display='inline-block';
}
async function bulkNextUnit(){
if(!BULK)return;
$('bkNext').disabled=true;
$('bulkProgress').textContent='Detecting the connected adapter...';
try{await bulkBurnStep();}finally{$('bkNext').disabled=false;}
}
// Pre-fill the manifest so barrett never has to type/paste a long path.
async function loadBulkDefaults(){
try{const {data}=await api('/bulk-defaults');
if(data&&data.ok){
if($('bkManifest')&&!$('bkManifest').value)$('bkManifest').value=data.manifest;
if(data.template&&$('bkTemplate'))$('bkTemplate').placeholder='blank = '+data.template.split('/').pop();
}}catch(e){}
}
// Pause the 5s refresh while a bulk field is focused, so a re-render never lands
// under the cursor while the operator is typing/pasting.
function bulkFieldFocused(){const a=document.activeElement;
return !!(a&&['bkBase','bkCount','bkStep','bkScheme','bkTemplate','bkManifest'].includes(a.id));}
loadBulkDefaults();
refresh(true);setInterval(()=>{if(!bulkFieldFocused())refresh(false);},5000);
</script></body></html>"""
def main():
port = int(os.environ.get("PORT", "8747"))
# Pre-populate the session with the CLI's default LAN7800 template so the
# UI shows sensible defaults on first load (source_kind == "defaults").
run_cli("new", "--out", WORK_BIN)
SOURCE["kind"], SOURCE["name"] = "defaults", None
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
log(f"LAN7800 webapp on http://127.0.0.1:{port} (work dir {WORK_DIR})")
print(f"LAN7800 EEPROM Programmer webapp: http://127.0.0.1:{port}")
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()