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 &rarr; write &rarr; verify &rarr;
      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)&hellip;</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 &rarr;</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 &#10003;</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()