#!/usr/bin/env python3
"""
web_serve.py — KL630 Web Control Panel

UniFi-style web UI: compile, deploy via Telnet, RTSP stream preview.

Usage:
    pip install -r requirements.txt
    python web_serve.py
    python web_serve.py --port 8080

# by mars
"""

import sys
import os
import re
import json
import shutil
import subprocess
import threading
import time
from pathlib import Path

try:
    from flask import Flask, jsonify, request, Response, send_from_directory, abort
except ImportError:
    print("ERROR: Flask not installed.  Run:  pip install -r requirements.txt")
    sys.exit(1)

import telnetlib  # stdlib, no install needed

try:
    import cv2
    HAS_CV2 = True
except Exception as _e:
    HAS_CV2 = False
    print(f"WARNING: opencv-python import failed: {_e}")

# ── Paths ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR    = Path(__file__).resolve().parent
BUILD_DIR     = SCRIPT_DIR / "build"
BINARY_NAME   = "kp_firmware_host_stream"
INI_SRC       = SCRIPT_DIR / "ini" / "host_stream.ini"
DEPLOY_SRC    = SCRIPT_DIR / "tools" / "device" / "deploy.sh"
DEMO_RTSP_SRC      = SCRIPT_DIR / "tools" / "device" / "demo_rtsp.sh"
DEMO_HDMI_SRC      = SCRIPT_DIR / "tools" / "device" / "demo_hdmi.sh"
DEMO_RTSP_HDMI_SRC = SCRIPT_DIR / "tools" / "device" / "demo_rtsp_hdmi.sh"
NEF_DIR          = SCRIPT_DIR / "nef"
KCURL_SRC        = Path("/home/user/Documents/GOFACE/奧創雲/kCurl-1.1.1-release/kCurl-linux-armv7")
CONFIG_FILE      = SCRIPT_DIR / ".web_config.json"
PROFILES_FILE    = SCRIPT_DIR / "model_profiles.json"

BIN_DIR_DEVICE = "/mnt/flash/plus/kp_firmware/kp_firmware_0/kp_firmware/bin"
FW_PATH_DEVICE = "/mnt/flash/vienna/kp_firmware_host_stream"

DEFAULT_CONFIG = {
    "host_ip":      "192.168.3.1",
    "kl630_ip":     "192.168.3.10",
    "port":         8080,
    "docker_image": "kl630-dev",
}

# ── Config ────────────────────────────────────────────────────────────────────
def load_config():
    if CONFIG_FILE.exists():
        try:
            return {**DEFAULT_CONFIG, **json.loads(CONFIG_FILE.read_text(encoding="utf-8"))}
        except Exception:
            pass
    return dict(DEFAULT_CONFIG)

def save_config(cfg):
    CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
    # Patch HOST_URL in deploy.sh to match the saved host_ip + port
    _patch_deploy_sh(f"http://{cfg['host_ip']}:{cfg['port']}")

def _patch_deploy_sh(host_url):
    """Rewrite HOST_URL line in deploy.sh (source if exists, always build copy)."""
    pattern = r'HOST_URL="[^"]*"'
    replacement = f'HOST_URL="{host_url}"'
    for target in [DEPLOY_SRC, BUILD_DIR / "deploy.sh"]:
        if target.exists():
            text = target.read_text(encoding="utf-8")
            target.write_text(re.sub(pattern, replacement, text), encoding="utf-8")

# ── Build dir helpers ─────────────────────────────────────────────────────────
def prepare_build_dir():
    BUILD_DIR.mkdir(exist_ok=True)
    for src, name in [
        (INI_SRC,        "host_stream.ini"),
        (DEPLOY_SRC,     "deploy.sh"),
        (DEMO_RTSP_SRC,      "demo_rtsp.sh"),
        (DEMO_HDMI_SRC,      "demo_hdmi.sh"),
        (DEMO_RTSP_HDMI_SRC, "demo_rtsp_hdmi.sh"),
    ]:
        if Path(src).exists():
            shutil.copy2(src, BUILD_DIR / name)
    # Copy kCurl ARM binary so device can wget it
    if KCURL_SRC.exists():
        shutil.copy2(KCURL_SRC, BUILD_DIR / "kCurl")
    # Copy all NEF models into build/nef/ so any model can be wget'd
    if NEF_DIR.exists():
        nef_dst_dir = BUILD_DIR / "nef"
        nef_dst_dir.mkdir(parents=True, exist_ok=True)
        for nef in NEF_DIR.iterdir():
            if nef.is_file() and nef.suffix.lower() == ".nef":
                shutil.copy2(nef, nef_dst_dir / nef.name)

def list_build_files():
    BUILD_DIR.mkdir(exist_ok=True)
    files = []
    for f in sorted(BUILD_DIR.rglob("*")):
        if f.is_file():
            files.append({"name": str(f.relative_to(BUILD_DIR)).replace("\\", "/"), "size": f.stat().st_size})
    return files

# ── SSE helpers ───────────────────────────────────────────────────────────────
def sse(text, kind="log"):
    payload = json.dumps({"kind": kind, "text": text.replace("\r", "")})
    return f"data: {payload}\n\n"

def sse_done():
    return sse("__DONE__", "done")

_compile_lock = threading.Lock()

# ── Flask app ─────────────────────────────────────────────────────────────────
app = Flask(__name__)

# ── Static file serving (for device wget at root path) ───────────────────────
TOOLS_DEVICE_DIR = SCRIPT_DIR / "tools" / "device"

@app.route("/<path:filename>")
def serve_build_file(filename):
    # Demo/deploy scripts are served directly from tools/device/ (source of truth).
    # Everything else (binary, NEF, INI, ...) comes from build/.
    tools_path = TOOLS_DEVICE_DIR / filename
    if tools_path.exists() and tools_path.is_file():
        return send_from_directory(TOOLS_DEVICE_DIR, filename)
    path = BUILD_DIR / filename
    if path.exists() and path.is_file():
        return send_from_directory(BUILD_DIR, filename)
    abort(404)

# ── API: config ───────────────────────────────────────────────────────────────
@app.route("/api/config", methods=["GET"])
def api_config_get():
    return jsonify(load_config())

@app.route("/api/config", methods=["POST"])
def api_config_post():
    cfg = load_config()
    data = request.get_json() or {}
    for k in ("host_ip", "kl630_ip", "port", "docker_image"):
        if k in data:
            cfg[k] = data[k]
    save_config(cfg)
    return jsonify({"ok": True})

# ── API: files ────────────────────────────────────────────────────────────────
@app.route("/api/files")
def api_files():
    return jsonify(list_build_files())

# ── API: compile (SSE) ────────────────────────────────────────────────────────
@app.route("/api/compile/run")
def api_compile_run():
    if not _compile_lock.acquire(blocking=False):
        def busy():
            yield sse("Another compile is already running.", "error")
            yield sse_done()
        return Response(busy(), mimetype="text/event-stream",
                        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

    cfg = load_config()

    def generate():
        try:
            docker_image = cfg["docker_image"]

            if shutil.which("docker") is None:
                yield sse("ERROR: docker not found in PATH", "error")
                return

            # Ensure image exists
            r = subprocess.run(["docker", "image", "inspect", docker_image],
                               capture_output=True)
            if r.returncode != 0:
                yield sse(f"Image '{docker_image}' not found — building from Dockerfile...", "warn")
                dockerfile = SCRIPT_DIR / "Dockerfile"
                if not dockerfile.exists():
                    yield sse(f"ERROR: Dockerfile not found", "error")
                    return
                proc = subprocess.Popen(
                    ["docker", "build", "-t", docker_image, str(SCRIPT_DIR)],
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                    encoding="utf-8", errors="replace", bufsize=1
                )
                for line in proc.stdout:
                    yield sse(line.rstrip())
                proc.wait()
                if proc.returncode != 0:
                    yield sse(f"Image build FAILED (exit {proc.returncode})", "error")
                    return
                yield sse(f"Image '{docker_image}' built.", "ok")
            else:
                yield sse(f"Image '{docker_image}' found.", "ok")

            # Cross-compile
            mount = str(SCRIPT_DIR).replace("\\", "/")
            cmd = [
                "docker", "run", "--rm",
                "-v", f"{mount}:/workspace/kl630_build",
                docker_image,
                "bash", "/workspace/kl630_build/compile.sh",
            ]
            yield sse(f"Starting cross-compile (ARM armv7-a)...")
            proc = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                encoding="utf-8", errors="replace", bufsize=1
            )
            for line in proc.stdout:
                yield sse(line.rstrip())
            proc.wait()

            if proc.returncode != 0:
                yield sse(f"Compile FAILED (exit {proc.returncode})", "error")
                return

            # Copy scripts to build/
            prepare_build_dir()
            yield sse("Copied INI + scripts to build/", "ok")
            yield sse("Compile SUCCESS", "ok")

        finally:
            _compile_lock.release()

        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── Telnet helper ─────────────────────────────────────────────────────────────
_SENTINEL = "__KL630_DONE__"

def _telnet_connect(ip, timeout=10):
    """
    Connect to KL630 Telnet (root, no password).
    KL630 BusyBox may auto-login or show login: — handles both.
    Returns telnetlib.Telnet ready to accept commands.
    """
    tn = telnetlib.Telnet(ip, 23, timeout=timeout)

    # Wait for either a login prompt or the shell '#' prompt (up to 5 s).
    # read_until is reliable; read_very_eager() was timing-dependent.
    try:
        data = tn.read_until(b"#", timeout=5).decode("utf-8", errors="replace")
    except Exception:
        data = ""

    if "login:" in data.lower():
        tn.write(b"root\n")
        try:
            after = tn.read_until(b"#", timeout=5).decode("utf-8", errors="replace")
        except Exception:
            after = ""
        if "password:" in after.lower():
            tn.write(b"\n")
            try:
                tn.read_until(b"#", timeout=5)
            except Exception:
                pass

    # Flush any trailing chars after '#' (e.g. the space in "# ")
    time.sleep(0.1)
    tn.read_very_eager()
    return tn

_ANSI_ESC = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r')

def _strip_ansi(text):
    return _ANSI_ESC.sub("", text)

def _telnet_run(tn, cmd, timeout=60):
    """
    Run cmd, block until sentinel received, return output lines.
    Uses read_until() so it reliably waits for slow commands (wget etc.).
    ANSI escape codes are stripped.
    If read_until times out (sentinel not found), the stale sentinel is drained
    before returning so subsequent commands are not contaminated.
    """
    tn.write(f"{cmd}; echo {_SENTINEL}\n".encode())
    try:
        data = tn.read_until(_SENTINEL.encode(), timeout=timeout)
    except EOFError:
        return []

    # Timed out — sentinel was NOT found in the returned data.
    # Send a fresh unique sync marker so read_until consumes ALL stale output
    # (including the original sentinel that may still be in transit).
    # This guarantees the buffer is clean before the next command runs.
    if _SENTINEL.encode() not in data:
        sync = f"__KL630_SYNC_{id(tn) & 0xFFFF:04X}__".encode()
        try:
            tn.write(b"echo " + sync + b"\n")
            tn.read_until(sync, timeout=25)
        except Exception:
            pass
        time.sleep(0.1)
        try:
            tn.read_very_eager()
        except Exception:
            pass
        return []

    text = _strip_ansi(data.decode("utf-8", errors="replace"))
    try:
        tn.read_until(b"\n", timeout=2)   # consume newline after sentinel
    except Exception:
        pass

    lines = []
    cmd_prefix = cmd[:40]
    for ln in text.splitlines():
        ln = ln.strip()
        if (ln
                and _SENTINEL not in ln
                and ln not in ("#", "$")
                and cmd_prefix not in ln):   # skip shell echo of the command
            lines.append(ln)
    return lines

def _telnet_run_bg(tn, cmd):
    """Fire a background command (nohup … &) without waiting."""
    tn.write(f"{cmd}\n".encode())
    time.sleep(1.5)
    try:
        out = tn.read_very_eager().decode("utf-8", errors="replace")
        return [ln.strip() for ln in out.splitlines() if ln.strip()]
    except Exception:
        return []

def _drain_shell(tn):
    """Drain all residual shell output and disable terminal echo.

    Root cause of cascade: BusyBox shell echoes every command back into the
    socket.  read_until(SENTINEL) finds the sentinel inside the command echo
    *before* the command executes, returns early, and leaves the real output +
    real sentinel in the buffer for the next command to misread.

    Fix: drain with a sentinel echo (works even when echo is on because
    read_very_eager() consumes the actual output), then run `stty -echo` so
    subsequent commands are NOT reflected back.
    """
    # Step 1 – drain any residual data (echo ON here, but read_very_eager
    # catches the actual sentinel output after read_until grabs the echo copy)
    sync = b"__KL630_RDY__"
    tn.write(b"echo " + sync + b"\n")
    try:
        tn.read_until(sync, timeout=10)   # finds sync in command echo — fine
    except Exception:
        pass
    time.sleep(0.1)
    try:
        tn.read_very_eager()   # drain actual sync output + prompt
    except Exception:
        pass

    # Step 2 – disable terminal echo so commands aren't reflected back
    tn.write(b"stty -echo\n")
    time.sleep(0.2)
    try:
        tn.read_very_eager()   # drain the echo of "stty -echo" itself + prompt
    except Exception:
        pass

def _sync_demo_scripts_on_device(tn, base, bd):
    """Sync demo restart scripts used by Apply-to-Device restart path."""
    cmds = [
        ("Syncing demo_rtsp.sh...",
         f"wget -q {base}/demo_rtsp.sh -O {bd}/ini/demo_rtsp.sh && chmod +x {bd}/ini/demo_rtsp.sh && cp {bd}/ini/demo_rtsp.sh {bd}/demo_rtsp.sh && cp {bd}/ini/demo_rtsp.sh /mnt/flash/vienna/demo_rtsp.sh && echo 'demo_rtsp.sh OK'",
         30),
        ("Syncing demo_hdmi.sh...",
         f"wget -q {base}/demo_hdmi.sh -O {bd}/ini/demo_hdmi.sh && chmod +x {bd}/ini/demo_hdmi.sh && cp {bd}/ini/demo_hdmi.sh {bd}/demo_hdmi.sh && cp {bd}/ini/demo_hdmi.sh /mnt/flash/vienna/demo_hdmi.sh && echo 'demo_hdmi.sh OK'",
         30),
        ("Syncing demo_rtsp_hdmi.sh...",
         f"wget -q {base}/demo_rtsp_hdmi.sh -O {bd}/ini/demo_rtsp_hdmi.sh && chmod +x {bd}/ini/demo_rtsp_hdmi.sh && cp {bd}/ini/demo_rtsp_hdmi.sh {bd}/demo_rtsp_hdmi.sh && cp {bd}/ini/demo_rtsp_hdmi.sh /mnt/flash/vienna/demo_rtsp_hdmi.sh && echo 'demo_rtsp_hdmi.sh OK'",
         30),
    ]

    out = []
    for label, cmd, timeout in cmds:
        out.append(("log", label))
        out.append(("prompt", f"$ {cmd}"))
        for ln in _telnet_run(tn, cmd, timeout=timeout):
            out.append(("ok", ln))
    return out

def _verify_firmware_running(tn):
    """Check whether firmware process is up after restart and return parsed result."""
    check_cmd = (
        "for i in 1 2 3 4 5; do "
        "pid=$(pidof kp_firmware_host_stream 2>/dev/null); "
        "if [ -n \"$pid\" ]; then echo \"FW PID: $pid\"; exit 0; fi; "
        "sleep 1; "
        "done; "
        "echo 'FW NOT RUNNING'; "
        "tail -n 30 /tmp/fw.log 2>/dev/null"
    )
    lines = _telnet_run(tn, check_cmd, timeout=20)
    ok = any("FW PID:" in ln for ln in lines)
    return ok, lines

# ── API: deploy via Telnet (SSE) ──────────────────────────────────────────────
@app.route("/api/deploy/run")
def api_deploy_run():
    cfg         = load_config()
    kl_ip       = request.args.get("ip",          cfg["kl630_ip"])
    h_ip        = request.args.get("host_ip",     cfg["host_ip"])
    port        = int(request.args.get("port",    cfg["port"]))
    out_rtsp = request.args.get("out_rtsp", "1") == "1"
    out_hdmi = request.args.get("out_hdmi", "0") == "1"
    base   = f"http://{h_ip}:{port}"
    bd     = BIN_DIR_DEVICE
    fw     = FW_PATH_DEVICE
    ini    = f"{bd}/ini/host_stream.ini"

    model_path   = _ini_get_str("ModelPath") or "nef/STDC_0520.nef"
    nef_filename = model_path.split("/")[-1]
    if out_hdmi and not out_rtsp:
        start_cmd   = f"cd {bd} && nohup sh ./ini/demo_hdmi.sh > /tmp/fw.log 2>&1 &"
        start_label = "Starting HDMI demo (background)..."
    elif out_rtsp and out_hdmi:
        start_cmd   = f"cd {bd} && nohup sh ./ini/demo_rtsp_hdmi.sh > /tmp/fw.log 2>&1 &"
        start_label = "Starting RTSP+HDMI demo (background)..."
    elif out_rtsp:
        start_cmd   = f"cd {bd} && nohup sh ./ini/demo_rtsp.sh > /tmp/fw.log 2>&1 &"
        start_label = "Starting RTSP demo (background)..."
    else:
        start_cmd   = f"cd {bd} && nohup LD_LIBRARY_PATH=/mnt/flash/vienna/lib {fw} > /tmp/fw.log 2>&1 &"
        start_label = "Starting firmware (inference only)..."

    voc_val    = 1 if out_hdmi else 0
    set_voc    = f"sed -i 's/^voc_enable.*/voc_enable = {voc_val}/' {ini} && echo 'voc OK'"

    # (label, cmd, background, timeout_s)
    steps = [
        ("Stopping old firmware...",
         "killall -9 kp_firmware_host_stream 2>/dev/null; killall -9 rtsps 2>/dev/null; sleep 1; rm -f /dev/shm/*",
         False, 30),
        ("Downloading firmware binary...",
         f"wget -q {base}/kp_firmware_host_stream -O {fw} && chmod +x {fw} && ls -lh {fw}",
         False, 60),
        ("Downloading INI...",
         f"wget -q {base}/host_stream.ini -O {ini} && echo 'INI OK'",
         False, 30),
        (f"Downloading NEF model ({nef_filename})...",
         f"mkdir -p {bd}/nef && wget -q {base}/nef/{nef_filename} -O {bd}/nef/{nef_filename} && echo 'NEF OK'",
         False, 180),
        ("Downloading demo_rtsp.sh...",
         f"wget -q {base}/demo_rtsp.sh -O {bd}/ini/demo_rtsp.sh && chmod +x {bd}/ini/demo_rtsp.sh && cp {bd}/ini/demo_rtsp.sh {bd}/demo_rtsp.sh && cp {bd}/ini/demo_rtsp.sh /mnt/flash/vienna/demo_rtsp.sh && echo 'demo_rtsp.sh OK'",
         False, 30),
        ("Downloading demo_hdmi.sh...",
         f"wget -q {base}/demo_hdmi.sh -O {bd}/ini/demo_hdmi.sh && chmod +x {bd}/ini/demo_hdmi.sh && cp {bd}/ini/demo_hdmi.sh {bd}/demo_hdmi.sh && cp {bd}/ini/demo_hdmi.sh /mnt/flash/vienna/demo_hdmi.sh && echo 'demo_hdmi.sh OK'",
         False, 30),
        ("Downloading demo_rtsp_hdmi.sh...",
         f"wget -q {base}/demo_rtsp_hdmi.sh -O {bd}/ini/demo_rtsp_hdmi.sh && chmod +x {bd}/ini/demo_rtsp_hdmi.sh && cp {bd}/ini/demo_rtsp_hdmi.sh {bd}/demo_rtsp_hdmi.sh && cp {bd}/ini/demo_rtsp_hdmi.sh /mnt/flash/vienna/demo_rtsp_hdmi.sh && echo 'demo_rtsp_hdmi.sh OK'",
         False, 30),
        ("Pushing deploy.sh to device...",
         f"wget -q {base}/deploy.sh -O /tmp/deploy.sh && chmod +x /tmp/deploy.sh && echo 'deploy.sh OK'",
         False, 30),
        ("Downloading kCurl (event upload tool)...",
         f"wget -q {base}/kCurl -O {bd}/kCurl && chmod +x {bd}/kCurl && echo 'kCurl OK'",
         False, 30),
        ("Setting VOC/HDMI output...", set_voc, False, 10),
        (start_label, start_cmd, True, 10),
    ]

    def generate():
        yield sse(f"Connecting to {kl_ip}:23 via Telnet...")
        try:
            tn = _telnet_connect(kl_ip)
            _drain_shell(tn)
            yield sse(f"Connected to {kl_ip}", "ok")
            for label, cmd, bg, timeout in steps:
                yield sse(label)
                yield sse(f"$ {cmd}", "prompt")
                if bg:
                    for ln in _telnet_run_bg(tn, cmd):
                        if ln.strip():
                            yield sse(ln.strip())
                else:
                    for ln in _telnet_run(tn, cmd, timeout=timeout):
                        yield sse(ln)
            tn.close()
            yield sse("Deploy complete. Firmware running in background.", "ok")
            yield sse(f"Firmware log: /tmp/fw.log", "log")
        except EOFError:
            yield sse("Connection closed by device.", "error")
        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── API: BT UART one-time baud setup via Telnet (SSE) ────────────────────────
@app.route("/api/bt_setup/run")
def api_bt_setup_run():
    cfg   = load_config()
    kl_ip = request.args.get("ip", cfg["kl630_ip"])
    bd    = BIN_DIR_DEVICE
    fw    = FW_PATH_DEVICE
    restart_cmd = (f"cd {bd} && rm -f /dev/shm/* && "
                   f"nohup sh ./ini/demo_rtsp.sh > /tmp/fw.log 2>&1 &")

    def generate():
        yield sse("⚠  請先關閉手機 nRF Connect 並確保 DX-BT24 未連線，再繼續！", "warn")
        yield sse(f"Connecting to {kl_ip}:23 via Telnet...")
        try:
            tn = _telnet_connect(kl_ip)
            _drain_shell(tn)
            yield sse("Connected.", "ok")

            # Step 1: kill firmware so UART is free
            yield sse("Step 1: stopping firmware...")
            _telnet_run(tn, "killall -9 kp_firmware_host_stream 2>/dev/null; "
                            "killall -9 rtsps 2>/dev/null; sleep 1; echo stopped", timeout=10)

            # Step 2: probe current baud by reading UART response
            yield sse("Step 2: probing module baud rate...")
            probe_115200 = (
                "stty -F /dev/ttyS1 115200 raw cs8 -parenb -cstopb -echo; "
                "cat /dev/ttyS1 > /tmp/_bt_resp.bin & CPID=$!; "
                "printf 'AT+BAUD\\r\\n' > /dev/ttyS1; sleep 1; "
                "kill $CPID 2>/dev/null; "
                "BYTES=$(wc -c < /tmp/_bt_resp.bin 2>/dev/null || echo 0); "
                "echo \"115200_bytes=$BYTES\"; "
                "[ \"$BYTES\" -gt 0 ] && cat /tmp/_bt_resp.bin || true"
            )
            resp_115200 = _telnet_run(tn, probe_115200, timeout=8)
            for ln in resp_115200:
                yield sse(ln, "ok")

            got_115200 = any("115200_bytes=" in ln and not ln.endswith("=0") for ln in resp_115200)

            if got_115200:
                yield sse("Module responded at 115200 — already configured!", "ok")
            else:
                yield sse("No response at 115200 — trying 9600...", "warn")
                probe_9600 = (
                    "stty -F /dev/ttyS1 9600 raw cs8 -parenb -cstopb -echo; "
                    "cat /dev/ttyS1 > /tmp/_bt_resp.bin & CPID=$!; "
                    "printf 'AT+BAUD\\r\\n' > /dev/ttyS1; sleep 1; "
                    "kill $CPID 2>/dev/null; "
                    "BYTES=$(wc -c < /tmp/_bt_resp.bin 2>/dev/null || echo 0); "
                    "echo \"9600_bytes=$BYTES\"; "
                    "[ \"$BYTES\" -gt 0 ] && cat /tmp/_bt_resp.bin || true"
                )
                resp_9600 = _telnet_run(tn, probe_9600, timeout=8)
                for ln in resp_9600:
                    yield sse(ln, "ok")

                got_9600 = any("9600_bytes=" in ln and not ln.endswith("=0") for ln in resp_9600)

                if got_9600:
                    yield sse("Module at 9600 — sending AT+BAUD7 to upgrade...", "ok")
                    upgrade = (
                        "stty -F /dev/ttyS1 9600 raw cs8 -parenb -cstopb -echo; "
                        "printf 'AT+BAUD7\\r\\n' > /dev/ttyS1; sleep 0.3; "
                        "stty -F /dev/ttyS1 115200 raw cs8 -parenb -cstopb -echo; "
                        "printf 'AT+RESET\\r\\n' > /dev/ttyS1; sleep 1.5; "
                        "echo 'upgrade_sent'"
                    )
                    for ln in _telnet_run(tn, upgrade, timeout=8):
                        yield sse(ln, "ok")
                    yield sse("AT+BAUD7 + AT+RESET sent. Module rebooting...", "ok")
                else:
                    yield sse("No response at 9600 either.", "warn")
                    yield sse("Can't probe module — it may be in BLE transparent mode or disconnected.", "warn")
                    yield sse("Make sure phone is DISCONNECTED from DX-BT24, then run this again.", "error")

            # Step 3: send test string at 115200 to verify
            yield sse("Step 3: sending test ping at 115200...")
            test_cmd = (
                "stty -F /dev/ttyS1 115200 raw cs8 -parenb -cstopb -echo; "
                "printf '{\"class\":\"test\",\"level\":0}' > /dev/ttyS1; "
                "echo 'ping_sent'"
            )
            for ln in _telnet_run(tn, test_cmd, timeout=5):
                yield sse(ln, "ok")

            # Step 4: restart firmware normally (bt_at_probe stays 0)
            yield sse("Step 4: restarting firmware...")
            _telnet_run(tn, "killall -9 kp_firmware_host_stream 2>/dev/null; "
                            "killall -9 rtsps 2>/dev/null; sleep 1; rm -f /dev/shm/*", timeout=10)
            _telnet_run_bg(tn, restart_cmd)
            yield sse("Firmware restarted.", "ok")

            tn.close()
            yield sse('完成！現在連上手機 → 訂閱 Notify → 如果看到 {"class":"test","level":0} 或 {"class":"boot","level":0} 表示成功', "ok")
        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── API: RTSP → MJPEG proxy (via opencv-python, no system ffmpeg needed) ──────
_stream_active = False

@app.route("/api/stream/video")
def api_stream_video():
    global _stream_active

    if not HAS_CV2:
        return "opencv-python not installed.  Run: pip install -r requirements.txt", 503

    cfg      = load_config()
    kl_ip    = request.args.get("ip", cfg["kl630_ip"])
    rtsp_url = f"rtsp://{kl_ip}/live1.sdp"

    # Suppress OpenCV WARN spam (e.g. "Stream timeout triggered")
    os.environ["OPENCV_LOG_LEVEL"] = "SILENT"
    # Force TCP, set 5-second connect timeout (microseconds)
    os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp|timeout;5000000"
    cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)

    # Try first frame with 6-second wall-clock timeout
    first = [False, None]
    def _read_first():
        first[0], first[1] = cap.read()
    t = threading.Thread(target=_read_first, daemon=True)
    t.start()
    t.join(timeout=6)

    if not first[0]:
        cap.release()
        return "RTSP stream not available — is firmware running?", 503

    def mjpeg_frames():
        global _stream_active
        _stream_active = True
        try:
            # Serve the first frame we already read
            _, jpeg = cv2.imencode(".jpg", cv2.resize(first[1], (960, 540)),
                                   [cv2.IMWRITE_JPEG_QUALITY, 70])
            yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpeg.tobytes() + b"\r\n"

            fail_count = 0
            while _stream_active:
                ret, frame = cap.read()
                if not ret:
                    fail_count += 1
                    if fail_count >= 10:
                        break
                    time.sleep(0.1)
                    continue
                fail_count = 0
                frame = cv2.resize(frame, (960, 540))
                _, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
                yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpeg.tobytes() + b"\r\n"
        finally:
            cap.release()
            _stream_active = False

    return Response(
        mjpeg_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame",
    )

@app.route("/api/stream/stop", methods=["POST"])
def api_stream_stop():
    global _stream_active
    _stream_active = False
    return jsonify({"ok": True})

# ── STDC live stats (background Telnet polling) ───────────────────────────────
_stdc_stats   = {}
_stdc_lock    = threading.Lock()
_stdc_running = False
_stdc_thread  = None

_RE_STDC_CLASS = re.compile(
    r'\[STDC\] frame=(\d+) mov=(\d+) diff=([\d.]+) '
    r'bunker=([\d.]+)% car=([\d.]+)% grass=([\d.]+)% greenery=([\d.]+)% '
    r'person=([\d.]+)% pond=([\d.]+)% road=([\d.]+)% tree=([\d.]+)%'
)
_RE_STDC_STATUS    = re.compile(r'\[STDC\] (ON ROAD|ON GRASS)')
_RE_STDC_GRASSTIME = re.compile(r'\[STDC\] ON GRASS: ([\d.]+)s')
_RE_STDC_WARN      = re.compile(r'\[STDC WARN\] (.+)')

def _parse_stdc_lines(lines):
    result = {}
    warns  = []
    for ln in reversed(lines):
        m = _RE_STDC_CLASS.search(ln)
        if m and 'road' not in result:
            result.update({
                'frame':    int(m.group(1)),
                'moving':   int(m.group(2)),
                'diff':     float(m.group(3)),
                'bunker':   float(m.group(4)),
                'car':      float(m.group(5)),
                'grass':    float(m.group(6)),
                'greenery': float(m.group(7)),
                'person':   float(m.group(8)),
                'pond':     float(m.group(9)),
                'road':     float(m.group(10)),
                'tree':     float(m.group(11)),
            })
        ms = _RE_STDC_STATUS.search(ln)
        if ms and 'status' not in result:
            result['status'] = ms.group(1)
        mg = _RE_STDC_GRASSTIME.search(ln)
        if mg and 'grass_time' not in result:
            result['grass_time']    = float(mg.group(1))
            result['grass_warning'] = '*** GRASS WARNING ***' in ln
        mw = _RE_STDC_WARN.search(ln)
        if mw:
            w = mw.group(1).strip()
            if w not in warns:
                warns.append(w)
    if warns:
        result['warns'] = list(reversed(warns))
    return result

def _stdc_poll_loop(ip):
    global _stdc_running
    tn = None
    while _stdc_running:
        try:
            if tn is None:
                tn = _telnet_connect(ip)
            lines = _telnet_run(tn, "tail -30 /tmp/fw.log 2>/dev/null", timeout=5)
            parsed = _parse_stdc_lines(lines)
            if parsed:
                with _stdc_lock:
                    _stdc_stats.update(parsed)
        except Exception:
            try:
                if tn: tn.close()
            except Exception:
                pass
            tn = None
            time.sleep(3)
            continue
        time.sleep(0.5)
    if tn:
        try: tn.close()
        except Exception: pass

@app.route("/api/stdc/start", methods=["POST"])
def api_stdc_start():
    global _stdc_running, _stdc_thread
    data = request.get_json(force=True) or {}
    ip   = data.get("ip", load_config()["kl630_ip"])
    if _stdc_running and _stdc_thread and _stdc_thread.is_alive():
        return jsonify({"ok": True, "msg": "already running"})
    _stdc_running = True
    with _stdc_lock:
        _stdc_stats.clear()
    _stdc_thread = threading.Thread(target=_stdc_poll_loop, args=(ip,), daemon=True)
    _stdc_thread.start()
    return jsonify({"ok": True})

@app.route("/api/stdc/stop", methods=["POST"])
def api_stdc_stop():
    global _stdc_running
    _stdc_running = False
    with _stdc_lock:
        _stdc_stats.clear()
    return jsonify({"ok": True})

@app.route("/api/stdc/stats")
def api_stdc_stats():
    with _stdc_lock:
        return jsonify(dict(_stdc_stats))

# ── Persistent terminal Telnet session ───────────────────────────────────────
_term_tn  = None
_term_ip  = None
_term_lock = threading.Lock()

def _get_term_tn(ip):
    global _term_tn, _term_ip
    # Reuse existing connection if same IP; reconnect if dead or changed
    if _term_tn is not None and _term_ip == ip:
        try:
            _term_tn.get_socket().getpeername()
            # Also verify the shell is actually responsive (TCP alive ≠ shell alive)
            _term_tn.write(b"echo __TN_ALIVE__\n")
            data = _term_tn.read_until(b"__TN_ALIVE__", timeout=3)
            if b"__TN_ALIVE__" in data:
                try:
                    _term_tn.read_until(b"\n", timeout=1)  # consume trailing newline
                except Exception:
                    pass
                return _term_tn
        except Exception:
            pass
    try:
        if _term_tn:
            _term_tn.close()
    except Exception:
        pass
    _term_tn = _telnet_connect(ip)
    _drain_shell(_term_tn)
    _term_ip = ip
    return _term_tn

# ── API: terminal exec (SSE) ──────────────────────────────────────────────────
@app.route("/api/terminal/exec")
def api_terminal_exec():
    cmd = request.args.get("cmd", "").strip()
    ip  = request.args.get("ip", load_config()["kl630_ip"])
    if not cmd:
        return jsonify({"error": "empty cmd"}), 400

    def generate():
        global _term_tn, _term_ip
        try:
            with _term_lock:
                tn = _get_term_tn(ip)
            lines = _telnet_run(tn, cmd, timeout=30)
            if lines:
                for ln in lines:
                    yield sse(ln, "log")
            else:
                yield sse("(no output)", "log")
        except Exception as e:
            with _term_lock:
                _term_tn = None      # force reconnect next time
            yield sse(f"Error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── API: write autostart script (SSE) ─────────────────────────────────────────
@app.route("/api/autostart/write")
def api_autostart_write():
    ip       = request.args.get("ip", load_config()["kl630_ip"])
    out_rtsp = request.args.get("out_rtsp", "1") == "1"
    out_hdmi = request.args.get("out_hdmi", "0") == "1"
    bd   = BIN_DIR_DEVICE
    fw   = FW_PATH_DEVICE
    ini  = f"{bd}/ini/host_stream.ini"

    # S95done in rcS checks /mnt/flash/etc/rc.local and runs it — this is the boot hook.
    # Must run in background (&) so init can continue to spawn the login shell.
    VIENNA = "/mnt/flash/vienna"
    target = "/mnt/flash/etc/rc.local"

    if out_hdmi and not out_rtsp:
        demo_line = f"nohup sh {VIENNA}/demo_hdmi.sh > /tmp/fw.log 2>&1 &"
    elif out_rtsp and out_hdmi:
        demo_line = f"nohup sh {VIENNA}/demo_rtsp_hdmi.sh > /tmp/fw.log 2>&1 &"
    elif out_rtsp:
        demo_line = f"nohup sh {VIENNA}/demo_rtsp.sh > /tmp/fw.log 2>&1 &"
    else:
        demo_line = f"nohup sh -c 'LD_LIBRARY_PATH={VIENNA}/lib {fw}' > /tmp/fw.log 2>&1 &"

    script_lines = [
        "#!/bin/sh",
        "# KL630 firmware autostart — written by web_serve.py",
        "mkdir -p /tmp/venc/c0/",
        "mkdir -p /tmp/aenc/c0/",
        "mkdir -p /tmp/playback/c0/",
        "mkdir -p /tmp/twoway/c0/",
        "mkdir -p /tmp/sr/c0/",
        "mkdir -p /tmp/sdcard",
        "mount /dev/mmcblk0p1 /tmp/sdcard 2>/dev/null || true",
        f"cd {VIENNA}/drivers && sh driver.sh 2>/dev/null",
        "sleep 2",
        f"export LD_LIBRARY_PATH={VIENNA}/lib",
        f"cd {VIENNA}",
        "sleep 3",
        demo_line,
    ]
    # Use printf with \n to avoid single-quote escaping issues in echo
    # Each line is written via printf '%s\n' "line" >> target
    def _esc_printf(s):
        # Escape double-quotes and backslashes for embedding in printf "..."
        return s.replace("\\", "\\\\").replace('"', '\\"')

    write_cmds = [f'printf "%s\\n" "{_esc_printf(ln)}" >> {target}' for ln in script_lines]

    modes = []
    if out_rtsp: modes.append("RTSP")
    if out_hdmi: modes.append("HDMI")
    mode_str = "+".join(modes) if modes else "inference-only"

    def generate():
        yield sse(f"Output mode: {mode_str}")
        yield sse(f"Connecting to {ip}:23...")
        try:
            tn = _telnet_connect(ip)
            _drain_shell(tn)
            yield sse("Connected.", "ok")

            # Ensure directory exists and clear existing file
            _telnet_run(tn, f"mkdir -p /mnt/flash/etc && rm -f {target}", timeout=5)

            for cmd in write_cmds:
                _telnet_run(tn, cmd, timeout=5)   # silent, just writing

            for ln in _telnet_run(tn, f"chmod +x {target} && echo 'chmod OK'"):
                yield sse(ln, "ok")
            yield sse(f"Script written to {target}", "ok")

            # Sync to flash before closing (UBIFS needs explicit sync or data is lost on power-off)
            _telnet_run(tn, "sync", timeout=10)

            # Verify
            for ln in _telnet_run(tn, f"cat {target}"):
                yield sse(ln, "log")

            tn.close()
            yield sse("Autostart configured. Will run on next boot.", "ok")
        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

@app.route("/api/mount_sd")
def api_mount_sd():
    ip = request.args.get("ip", load_config()["kl630_ip"])

    def generate():
        yield sse(f"Connecting to {ip}:23...")
        try:
            tn = _telnet_connect(ip)
            _drain_shell(tn)
            yield sse("Connected.", "ok")

            for ln in _telnet_run(tn, "mkdir -p /tmp/sdcard", timeout=5):
                yield sse(ln, "log")
            for ln in _telnet_run(tn, "mount /dev/mmcblk0p1 /tmp/sdcard 2>&1 || echo 'already mounted or no SD card'", timeout=10):
                yield sse(ln, "log")
            for ln in _telnet_run(tn, "df -h /tmp/sdcard", timeout=5):
                yield sse(ln, "ok")

            tn.close()
            yield sse("SD card mount done.", "ok")
        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})


@app.route("/api/autostart/read")
def api_autostart_read():
    ip     = request.args.get("ip", load_config()["kl630_ip"])
    target = "/mnt/flash/etc/rc.local"
    def generate():
        yield sse(f"Reading {target} from {ip}...")
        try:
            tn = _telnet_connect(ip)
            _drain_shell(tn)
            lines = _telnet_run(tn, f"cat {target} 2>/dev/null || echo '(file not found)'")
            tn.close()
            for ln in lines:
                yield sse(ln, "log")
        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()
    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── INI helpers ──────────────────────────────────────────────────────────────
def _ini_get(key):
    """Read a key's integer value from host_stream.ini."""
    try:
        text = INI_SRC.read_text(encoding="utf-8")
        m = re.search(rf"^\s*{re.escape(key)}\s*=\s*(\d+)", text, re.MULTILINE)
        return int(m.group(1)) if m else None
    except Exception:
        return None

def _ini_set(key, value):
    """Patch an integer key in host_stream.ini (preserves comments) and refresh build/."""
    text = INI_SRC.read_text(encoding="utf-8")
    text = re.sub(
        rf"^(\s*{re.escape(key)}\s*=\s*)\d+",
        lambda m: m.group(1) + str(value),
        text, flags=re.MULTILINE
    )
    INI_SRC.write_text(text, encoding="utf-8")
    prepare_build_dir()

def _ini_get_str(key):
    """Read a quoted string value from host_stream.ini (e.g. ModelPath)."""
    try:
        text = INI_SRC.read_text(encoding="utf-8")
        m = re.search(rf'^\s*{re.escape(key)}\s*=\s*"([^"]*)"', text, re.MULTILINE)
        return m.group(1) if m else None
    except Exception:
        return None

def _ini_set_str(key, value):
    """Patch a quoted string key in host_stream.ini (e.g. ModelPath)."""
    text = INI_SRC.read_text(encoding="utf-8")
    text = re.sub(
        rf'^(\s*{re.escape(key)}\s*=\s*)"[^"]*"',
        lambda m: m.group(1) + f'"{value}"',
        text, flags=re.MULTILINE
    )
    INI_SRC.write_text(text, encoding="utf-8")
    prepare_build_dir()

# ── API: INI read ─────────────────────────────────────────────────────────────
@app.route("/api/ini", methods=["GET"])
def api_ini_get():
    return jsonify({
        "fec_mode":            _ini_get("fec_mode"),
        "initial_fec_app_type": _ini_get("initial_fec_app_type"),
        "eis_enable":          _ini_get("eis_enable"),
        "draw_box_enable":     _ini_get("DrawBoxEnable"),
        "voc_enable":          _ini_get("voc_enable"),
        "verbose_log":         _ini_get("verbose_log"),
    })

# ── API: INI write + optional device apply (SSE) ─────────────────────────────
@app.route("/api/ini/apply")
def api_ini_apply():
    fec_mode    = int(request.args.get("fec_mode",    0))
    app_type    = int(request.args.get("app_type",    0))
    eis         = int(request.args.get("eis",          0))
    draw_box    = int(request.args.get("draw_box",     0))
    verbose_log     = int(request.args.get("verbose_log",     0))
    out_rtsp  = request.args.get("out_rtsp", "1") == "1"
    out_hdmi  = request.args.get("out_hdmi", "0") == "1"
    apply_dev = request.args.get("device", "0") == "1"
    cfg       = load_config()
    kl_ip     = request.args.get("ip", cfg["kl630_ip"])
    base      = f"http://{cfg['host_ip']}:{cfg['port']}"
    bd        = BIN_DIR_DEVICE
    ini_dev   = f"{bd}/ini/host_stream.ini"

    def generate():
        # 1. Update INI on disk (including voc_enable)
        voc_val = 1 if out_hdmi else 0
        _ini_set("fec_mode",             fec_mode)
        _ini_set("initial_fec_app_type", app_type)
        _ini_set("eis_enable",           eis)
        _ini_set("DrawBoxEnable",        draw_box)
        _ini_set("voc_enable",           voc_val)
        _ini_set("verbose_log",          verbose_log)
        yield sse(f"INI updated: fec_mode={fec_mode}  app_type={app_type}  eis={eis}  draw_box={draw_box}  voc={voc_val}  verbose_log={verbose_log}", "ok")

        if not apply_dev:
            yield sse_done()
            return

        # 2. Push to device via Telnet + restart firmware
        yield sse(f"Connecting to {kl_ip}:23...")
        try:
            tn = _telnet_connect(kl_ip)
            _drain_shell(tn)
            yield sse("Connected.", "ok")

            sed_cmds = [
                f"sed -i 's/^fec_mode.*/fec_mode = {fec_mode}/' {ini_dev} && echo 'fec_mode OK'",
                f"sed -i 's/^initial_fec_app_type.*/initial_fec_app_type = {app_type}/' {ini_dev} && echo 'app_type OK'",
                f"sed -i 's/^eis_enable.*/eis_enable = {eis}/' {ini_dev} && echo 'eis OK'",
                f"sed -i 's/^DrawBoxEnable.*/DrawBoxEnable = {draw_box}/' {ini_dev} && echo 'draw_box OK'",
                f"sed -i 's/^verbose_log.*/verbose_log = {verbose_log}/' {ini_dev} && echo 'verbose_log OK'",
            ]
            for cmd in sed_cmds:
                yield sse(f"$ {cmd}", "prompt")
                for ln in _telnet_run(tn, cmd):
                    yield sse(ln, "ok")

            # Set VOC/HDMI output mode
            _telnet_run(tn, f"sed -i 's/^voc_enable.*/voc_enable = {voc_val}/' {ini_dev}")

            # Keep restart scripts in sync with host so Apply behaves like Deploy.
            for kind, text in _sync_demo_scripts_on_device(tn, base, bd):
                yield sse(text, kind)

            # Restart firmware — choose script based on output mode
            yield sse("Restarting firmware...")
            _telnet_run(tn, "killall -9 kp_firmware_host_stream 2>/dev/null; killall -9 rtsps 2>/dev/null; sleep 1; rm -f /dev/shm/*")
            if out_hdmi and not out_rtsp:
                restart_cmd = f"cd {bd} && nohup sh ./ini/demo_hdmi.sh > /tmp/fw.log 2>&1 &"
            elif out_rtsp and out_hdmi:
                restart_cmd = f"cd {bd} && nohup sh ./ini/demo_rtsp_hdmi.sh > /tmp/fw.log 2>&1 &"
            elif out_rtsp:
                restart_cmd = f"cd {bd} && nohup sh ./ini/demo_rtsp.sh > /tmp/fw.log 2>&1 &"
            else:
                restart_cmd = f"cd {bd} && nohup LD_LIBRARY_PATH=/mnt/flash/vienna/lib {FW_PATH_DEVICE} > /tmp/fw.log 2>&1 &"
            _telnet_run_bg(tn, restart_cmd)
            ok, lines = _verify_firmware_running(tn)
            for ln in lines:
                yield sse(ln, "log" if ok else "warn")
            tn.close()
            if ok:
                yield sse("Firmware restarted.", "ok")
            else:
                yield sse("Restart command sent, but firmware is not running. Use Deploy to fully resync binary/scripts.", "error")

        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")

        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── API: model management ────────────────────────────────────────────────────
@app.route("/api/model/list")
def api_model_list():
    nef_build = BUILD_DIR / "nef"
    nef_build.mkdir(parents=True, exist_ok=True)
    files = [
        {"name": f.name, "size": f.stat().st_size}
        for f in sorted(nef_build.iterdir())
        if f.is_file() and f.suffix.lower() == ".nef"
    ]
    return jsonify(files)

@app.route("/api/model")
def api_model_get():
    return jsonify({
        "model_path": _ini_get_str("ModelPath"),
        "model_id":   _ini_get("ModelId"),
        "job_id":     _ini_get("JobId"),
    })

@app.route("/api/model/upload", methods=["POST"])
def api_model_upload():
    if "file" not in request.files:
        return jsonify({"error": "no file"}), 400
    f = request.files["file"]
    if not f.filename.lower().endswith(".nef"):
        return jsonify({"error": "only .nef files are accepted"}), 400
    nef_build = BUILD_DIR / "nef"
    nef_build.mkdir(parents=True, exist_ok=True)
    NEF_DIR.mkdir(exist_ok=True)
    safe_name = re.sub(r"[^a-zA-Z0-9._\-]", "_", f.filename)
    f.save(str(nef_build / safe_name))          # serve immediately
    shutil.copy2(nef_build / safe_name, NEF_DIR / safe_name)  # keep source in sync
    return jsonify({"ok": True, "filename": safe_name, "size": (nef_build / safe_name).stat().st_size})

@app.route("/api/model/profiles", methods=["GET", "POST"])
def api_model_profiles():
    if request.method == "POST":
        profiles = request.get_json(force=True)
        if not isinstance(profiles, list):
            return jsonify({"error": "expected a JSON array"}), 400
        PROFILES_FILE.write_text(json.dumps(profiles, indent=2, ensure_ascii=False))
        return jsonify({"ok": True})
    if PROFILES_FILE.exists():
        return jsonify(json.loads(PROFILES_FILE.read_text()))
    return jsonify([])

@app.route("/api/model/apply")
def api_model_apply():
    model_path = request.args.get("model_path", "").strip()
    model_id   = int(request.args.get("model_id", 32769))
    job_id     = int(request.args.get("job_id",   200))
    apply_dev  = request.args.get("device", "0") == "1"
    out_rtsp   = request.args.get("out_rtsp", "1") == "1"
    out_hdmi   = request.args.get("out_hdmi", "0") == "1"
    cfg        = load_config()
    kl_ip      = request.args.get("ip", cfg["kl630_ip"])
    base       = f"http://{cfg['host_ip']}:{cfg['port']}"
    bd         = BIN_DIR_DEVICE
    ini_dev    = f"{bd}/ini/host_stream.ini"

    def generate():
        # 1. Update INI on disk
        if model_path:
            _ini_set_str("ModelPath", model_path)
        _ini_set("ModelId", model_id)
        _ini_set("JobId",   job_id)
        yield sse(f"INI saved: ModelPath={model_path}  ModelId={model_id}  JobId={job_id}", "ok")

        if not apply_dev:
            yield sse_done()
            return

        # 2. Apply to device via Telnet
        nef_filename = model_path.split("/")[-1] if model_path else ""
        yield sse(f"Connecting to {kl_ip}:23...")
        try:
            tn = _telnet_connect(kl_ip)
            _drain_shell(tn)
            yield sse("Connected.", "ok")

            # Patch INI on device
            sed_cmds = []
            if model_path:
                sed_cmds.append(
                    f"sed -i 's|^ModelPath.*|ModelPath = \"{model_path}\"|' {ini_dev} && echo 'ModelPath OK'"
                )
            sed_cmds += [
                f"sed -i 's/^ModelId.*/ModelId = {model_id}/' {ini_dev} && echo 'ModelId OK'",
                f"sed -i 's/^JobId.*/JobId = {job_id}/' {ini_dev} && echo 'JobId OK'",
            ]
            for cmd in sed_cmds:
                yield sse(f"$ {cmd}", "prompt")
                for ln in _telnet_run(tn, cmd):
                    yield sse(ln, "ok")

            # Download NEF to device
            if nef_filename:
                nef_cmd = (f"mkdir -p {bd}/nef && "
                           f"wget -q {base}/nef/{nef_filename} -O {bd}/nef/{nef_filename} && "
                           f"echo 'NEF OK'")
                yield sse(f"Downloading NEF: {nef_filename} (may take a moment)...")
                yield sse(f"$ {nef_cmd}", "prompt")
                for ln in _telnet_run(tn, nef_cmd, timeout=180):
                    yield sse(ln)

            # Keep restart scripts in sync with host so Apply behaves like Deploy.
            for kind, text in _sync_demo_scripts_on_device(tn, base, bd):
                yield sse(text, kind)

            # Restart firmware — choose script based on output mode
            yield sse("Restarting firmware...")
            _telnet_run(tn, "killall -9 kp_firmware_host_stream 2>/dev/null; "
                            "killall -9 rtsps 2>/dev/null; sleep 1; rm -f /dev/shm/*")
            model_env = f"MODEL_PATH='{model_path}' MODEL_ID={model_id} JOB_ID={job_id}"
            if out_hdmi and not out_rtsp:
                restart_cmd = f"cd {bd} && nohup {model_env} sh ./ini/demo_hdmi.sh > /tmp/fw.log 2>&1 &"
            elif out_rtsp and out_hdmi:
                restart_cmd = f"cd {bd} && nohup {model_env} sh ./ini/demo_rtsp_hdmi.sh > /tmp/fw.log 2>&1 &"
            elif out_rtsp:
                restart_cmd = f"cd {bd} && nohup {model_env} sh ./ini/demo_rtsp.sh > /tmp/fw.log 2>&1 &"
            else:
                restart_cmd = f"cd {bd} && nohup LD_LIBRARY_PATH=/mnt/flash/vienna/lib {FW_PATH_DEVICE} > /tmp/fw.log 2>&1 &"
            _telnet_run_bg(tn, restart_cmd)
            ok, lines = _verify_firmware_running(tn)
            for ln in lines:
                yield sse(ln, "log" if ok else "warn")
            tn.close()
            if ok:
                yield sse("Firmware restarted with new model.", "ok")
            else:
              yield sse("Restart command sent, but firmware is not running. Use Deploy to fully resync binary/scripts.", "error")

        except Exception as e:
            yield sse(f"Telnet error: {e}", "error")
        yield sse_done()

    return Response(generate(), mimetype="text/event-stream",
                    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})

# ── Web UI (served at /) ──────────────────────────────────────────────────────
@app.route("/")
def index():
    return HTML_TEMPLATE

# ── HTML Template ─────────────────────────────────────────────────────────────
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KL630 Control</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#0f172a;--surface:#1e293b;--surface2:#263244;--border:#2d3f55;
  --primary:#38bdf8;--primary-dk:#0ea5e9;
  --text:#f1f5f9;--muted:#64748b;--muted2:#94a3b8;
  --ok:#4ade80;--warn:#fb923c;--err:#f87171;
  --term:#080d18;
  /* by mars */
}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;font-size:14px}
/* Nav */
nav{background:#080d18;border-bottom:1px solid var(--border);height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:sticky;top:0;z-index:50}
.brand{display:flex;align-items:center;gap:10px;font-weight:600;font-size:15px;letter-spacing:.01em}
.brand svg{color:var(--primary)}
.nav-right{display:flex;align-items:center;gap:16px;font-size:12px;color:var(--muted2)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);display:inline-block;margin-right:5px;transition:background .3s}
.dot.on{background:var(--ok);box-shadow:0 0 6px var(--ok)}
/* Layout */
main{max-width:1280px;margin:0 auto;padding:20px 24px}
.grid-top{display:grid;grid-template-columns:280px 1fr 340px;gap:16px;margin-bottom:16px}
@media(max-width:1100px){.grid-top{grid-template-columns:1fr 1fr}}
@media(max-width:700px){.grid-top{grid-template-columns:1fr}}
/* Card */
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:18px}
.card-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:14px}
/* Form */
label{display:block;font-size:11px;color:var(--muted2);margin:10px 0 3px}
label:first-of-type{margin-top:0}
input{width:100%;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-size:13px;outline:none;transition:border .15s}
input:focus{border-color:var(--primary)}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 15px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--primary-dk);color:#fff}
.btn-primary:hover:not(:disabled){background:var(--primary);color:#000}
.btn-ok{background:#15803d;color:#fff}
.btn-ok:hover:not(:disabled){background:var(--ok);color:#000}
.btn-warn{background:#9a3412;color:#fff}
.btn-warn:hover:not(:disabled){background:var(--warn);color:#000}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--muted2)}
.btn-ghost:hover:not(:disabled){border-color:var(--primary);color:var(--primary)}
.btn-sm{padding:5px 10px;font-size:11px}
/* Action bar */
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:13px 18px;margin-bottom:16px}
.act-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-right:4px}
/* File list */
.file-list{list-style:none}
.file-item{display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid #1b2535;font-size:12px}
.file-item:last-child{border-bottom:none}
.file-name{color:var(--primary);font-family:monospace;text-decoration:none}
.file-name:hover{text-decoration:underline}
.file-sz{color:var(--muted);font-size:11px}
.file-empty{color:var(--muted);font-size:12px;font-style:italic}
.file-group{margin-bottom:4px}
.file-group summary{cursor:pointer;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:4px 0;user-select:none;list-style:none;display:flex;align-items:center;gap:5px}
.file-group summary::-webkit-details-marker{display:none}
.file-group summary::before{content:'▶';font-size:9px;transition:transform .15s}
.file-group[open] summary::before{transform:rotate(90deg)}
.file-group summary .fg-count{color:#4a5568;font-weight:400;text-transform:none;letter-spacing:0}
/* RTSP stream */
.stream-box{background:#000;border-radius:6px;overflow:hidden;position:relative;min-height:180px;display:flex;align-items:center;justify-content:center}
.stream-box img{width:100%;display:block}
.stream-placeholder{color:var(--muted);font-size:12px;position:absolute}
/* Fullscreen */
#stream-box:fullscreen,#stream-box:-webkit-full-screen{width:100vw;height:100vh;background:#000;border-radius:0;min-height:100vh}
#stream-box:fullscreen #stream-img,#stream-box:-webkit-full-screen #stream-img{width:100%;height:100vh;object-fit:contain}
/* STDC panel below stream */
#stdc-panel-bars{}
.stream-url{font-family:monospace;font-size:11px;background:var(--term);border:1px solid var(--border);border-radius:4px;padding:5px 9px;color:var(--primary);display:block;margin-top:8px;word-break:break-all}
/* Terminal */
.term-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:7px}
.term-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}
#log{background:var(--term);border:1px solid var(--border);border-radius:10px;padding:14px 16px;font-family:'JetBrains Mono','Fira Code',Consolas,monospace;font-size:12px;height:380px;overflow-y:auto;line-height:1.65}
.ll{display:block}.ll.ok{color:var(--ok)}.ll.error{color:var(--err)}.ll.warn{color:var(--warn)}.ll.prompt{color:#3a6a9a}.ll.log{color:var(--muted2)}
@keyframes spin{to{transform:rotate(360deg)}}
.spin{display:inline-block;width:12px;height:12px;border:2px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
/* Collapsible card */
.card-title.collapsible{cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}
.card-title.collapsible:hover{color:var(--text)}
.collapse-arrow{margin-left:auto;font-size:10px;color:var(--muted);transition:transform .2s;flex-shrink:0}
/* Toggle switch */
.switch{position:relative;display:inline-block;width:38px;height:22px;flex-shrink:0}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;cursor:pointer;inset:0;background:#334155;border-radius:22px;transition:.2s}
.slider:before{content:"";position:absolute;width:16px;height:16px;left:3px;bottom:3px;background:#94a3b8;border-radius:50%;transition:.2s}
input:checked+.slider{background:var(--primary-dk)}
input:checked+.slider:before{transform:translateX(16px);background:#fff}
</style>
</head>
<body>

<!-- by mars -->
<nav>
  <div class="brand">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
      <rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
    </svg>
    KL630 Control
  </div>
</nav>

<main>
  <div class="grid-top">

    <!-- Network Config -->
    <div class="card">
      <div class="card-title">Network Config</div>
      <label>Host IP (this PC)</label>
      <input id="host_ip" type="text" placeholder="192.168.3.1">
      <label>KL630 IP</label>
      <input id="kl630_ip" type="text" placeholder="192.168.3.10">
      <label>HTTP Port</label>
      <input id="port" type="number" placeholder="8080">
      <label>Docker Image</label>
      <input id="docker_image" type="text" placeholder="kl630-dev">
      <div style="margin-top:14px;display:flex;gap:8px">
        <button class="btn btn-primary" onclick="saveConfig()">Save</button>
      </div>
    </div>

    <!-- Firmware Files -->
    <div class="card">
      <div class="card-title">
        HTTP Server Files
        <button class="btn btn-ghost btn-sm" style="float:right;margin-top:-2px" onclick="refreshFiles()">Refresh</button>
      </div>
      <ul class="file-list" id="file-list"><li class="file-empty">Loading...</li></ul>
      <div style="margin-top:12px;font-size:11px;color:var(--muted)">
        Device wget base URL:<br>
        <code id="serve-base" style="color:var(--primary);font-size:11px"></code>
      </div>
      <div style="margin-top:10px;font-size:11px;color:var(--muted)">Quick deploy cmd (on device):</div>
      <code id="deploy-cmd" style="font-size:10px;color:var(--muted2);display:block;margin-top:4px;word-break:break-all;line-height:1.5"></code>
    </div>

    <!-- RTSP Stream -->
    <div class="card">
      <div class="card-title">RTSP Stream Preview</div>
      <div class="stream-box" id="stream-box">
        <span class="stream-placeholder" id="stream-ph">Stream not started</span>
        <img id="stream-img" style="display:none" alt="RTSP stream">

        <!-- ROI boxes (SVG, always shown when AI overlay on) -->
        <svg id="stdc-roi-svg" viewBox="0 0 100 100" preserveAspectRatio="none"
             style="display:none;position:absolute;inset:0;width:100%;height:100%;pointer-events:none">
          <!-- Collision ROI: 25%–75% x, 25%–70% y (blue → red on collision) -->
          <rect id="roi-col" x="25" y="25" width="50" height="45"
                fill="none" stroke="rgba(59,130,246,0.85)" stroke-width="0.6" stroke-dasharray="2,1"/>
          <!-- Forward ROI trapezoid: top 45%–55%, bottom 30%–70%, y 55%–95% (cyan) -->
          <polygon id="roi-fwd" points="45,55 55,55 70,95 30,95"
                   fill="none" stroke="rgba(0,255,220,0.85)" stroke-width="0.6"/>
        </svg>

        <!-- STDC fullscreen overlay (semi-transparent, fullscreen only) -->
        <div id="stdc-overlay" style="display:none;position:absolute;inset:0;pointer-events:none;padding:10px;flex-direction:column;justify-content:space-between">
          <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
            <div id="stdc-bars-fs" style="background:rgba(0,0,0,0.65);border-radius:6px;padding:8px 10px"></div>
            <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
              <div id="stdc-status-fs"></div>
              <div id="stdc-warns-fs"></div>
            </div>
          </div>
          <div id="stdc-fi-fs" style="background:rgba(0,0,0,0.5);border-radius:4px;padding:2px 8px;font-size:11px;color:#94a3b8;font-family:monospace;align-self:flex-start"></div>
        </div>
      </div>

      <span class="stream-url" id="rtsp-url">rtsp://—/live1.sdp</span>
      <div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
        <button class="btn btn-ok btn-sm" id="btn-stream" onclick="toggleStream()">▶ Start Stream</button>
        <button class="btn btn-ghost btn-sm" onclick="copyRtsp()">Copy URL</button>
        <button class="btn btn-ghost btn-sm" id="btn-fs" onclick="toggleFullscreen()" style="display:none">⛶ Fullscreen</button>
        <div style="margin-left:auto;display:flex;align-items:center;gap:6px">
          <label class="switch">
            <input type="checkbox" id="stdc-toggle" checked onchange="onStdcToggle(this.checked)">
            <span class="slider"></span>
          </label>
          <span style="font-size:11px;color:var(--muted2)">AI Overlay</span>
        </div>
      </div>
    </div>

  </div><!-- /grid-top -->

  <!-- STDC AI Stats Panel (full-width, normal mode only) -->
  <div id="stdc-panel" style="display:none;margin-bottom:16px">
    <div class="card" style="margin:0">
      <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
        <div id="stdc-status-p" style="flex-shrink:0;font-size:13px;font-weight:600"></div>
        <div id="stdc-warns-p" style="display:flex;gap:6px;flex-wrap:wrap"></div>
        <div id="stdc-fi-p" style="font-size:10px;color:var(--muted);font-family:monospace;white-space:nowrap;margin-left:auto"></div>
      </div>
      <div id="stdc-panel-bars" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px 16px"></div>
    </div>
  </div>

  <!-- INI Settings -->
  <div class="card" style="margin-bottom:16px">
    <div class="card-title collapsible" onclick="toggleCard('ini-body','ini-arrow')">
      INI Settings
      <span id="ini-saved-badge" style="display:none;font-size:10px;color:var(--ok)">● Saved</span>
      <span id="ini-arrow" class="collapse-arrow">▶</span>
    </div>
    <div id="ini-body" style="display:none;margin-top:14px">
      <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;align-items:end">

        <!-- FEC toggle + mode -->
        <div>
          <div style="font-size:12px;color:var(--muted2);margin-bottom:6px">Fish-Eye Correction (FEC)</div>
          <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
            <label class="switch">
              <input type="checkbox" id="fec-toggle" onchange="onFecToggle()">
              <span class="slider"></span>
            </label>
            <span id="fec-state-label" style="font-size:12px;color:var(--muted2)">OFF</span>
          </div>
          <select id="fec-mode" style="width:100%;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-size:12px;outline:none" disabled>
            <option value="1">1 — Single Region</option>
            <option value="2">2 — 180° All Direction</option>
            <option value="3">3 — 180° One Direction</option>
            <option value="4">4 — 180° Two Direction</option>
            <!-- <option value="5">5 — PT Mode</option> 設計給可旋轉/傾斜的攝影機使用，需要對應的鏡頭幾何參數才能運作，一般固定式魚眼鏡頭跑它沒有意義。 -->
          </select>
        </div>

        <!-- Install type + EIS -->
        <div>
          <label style="font-size:11px;color:var(--muted2);display:block;margin-bottom:3px">Install Type</label>
          <select id="fec-app-type" style="width:100%;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;margin-bottom:10px">
            <option value="0">0 — Ceiling</option>
            <option value="1">1 — Table</option>
            <option value="2">2 — Wall</option>
          </select>
          <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
            <label class="switch">
              <input type="checkbox" id="eis-toggle">
              <span class="slider"></span>
            </label>
            <span style="font-size:12px;color:var(--muted2)">EIS (Electronic Image Stabilization)</span>
          </div>
          <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
            <label class="switch">
              <input type="checkbox" id="draw-box-toggle">
              <span class="slider"></span>
            </label>
            <span style="font-size:12px;color:var(--muted2)">DrawBox (H.264 burn-in overlay)</span>
          </div>
          <div style="display:flex;align-items:center;gap:10px">
            <label class="switch">
              <input type="checkbox" id="verbose-log-toggle">
              <span class="slider"></span>
            </label>
            <span style="font-size:12px;color:var(--muted2)">Verbose Log <span style="color:var(--warn)">(fills /tmp fast — demo/debug only)</span></span>
          </div>
        </div>

        <!-- Output mode + Buttons -->
        <div style="display:flex;flex-direction:column;gap:8px">
          <div>
            <label style="font-size:11px;color:var(--muted2);display:block;margin-bottom:6px">Output Mode</label>
            <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
              <label class="switch">
                <input type="checkbox" id="out-rtsp" checked>
                <span class="slider"></span>
              </label>
              <span style="font-size:12px;color:var(--muted2)">RTSP (H.264 streaming)</span>
            </div>
            <div style="display:flex;align-items:center;gap:10px">
              <label class="switch">
                <input type="checkbox" id="out-hdmi">
                <span class="slider"></span>
              </label>
              <span style="font-size:12px;color:var(--muted2)">HDMI (VOC display)</span>
            </div>
          </div>
          <button class="btn btn-ghost" onclick="saveIni()">Save INI (disk only)</button>
          <button class="btn btn-ok" onclick="applyIni()">Apply to Device + Restart</button>
        </div>

      </div>
    </div>
  </div>

  <!-- Model Settings -->
  <div class="card" style="margin-bottom:16px">
    <div class="card-title collapsible" onclick="toggleCard('model-body','model-arrow')">
      Model Settings
      <span id="model-saved-badge" style="display:none;font-size:10px;color:var(--ok)">● Saved</span>
      <span id="model-arrow" class="collapse-arrow">▶</span>
    </div>
    <div id="model-body" style="display:none;margin-top:14px">
      <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;align-items:end">

        <!-- NEF file selection + upload -->
        <div>
          <div style="font-size:11px;color:var(--muted2);margin-bottom:6px">NEF Model File</div>
          <div style="display:flex;gap:6px;margin-bottom:6px">
            <select id="model-nef-select" style="flex:1;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-size:12px;outline:none">
              <option value="">Loading...</option>
            </select>
            <label class="btn btn-ghost btn-sm" style="cursor:pointer;white-space:nowrap">
              ↑ Upload
              <input type="file" accept=".nef" style="display:none" onchange="uploadNef(this)">
            </label>
          </div>
          <div id="upload-status" style="font-size:11px;min-height:16px"></div>
        </div>

        <!-- ModelId + JobId -->
        <div>
          <label style="font-size:11px;color:var(--muted2);display:block;margin-bottom:3px">Model ID</label>
          <input id="model-id" type="number" placeholder="32769" style="margin-bottom:8px">
          <label style="font-size:11px;color:var(--muted2);display:block;margin-bottom:3px">Job ID</label>
          <select id="job-id" onchange="onJobIdChange()" style="width:100%;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-size:12px;outline:none">
            <option value="200">200 — STDC segmentation</option>
            <option value="11">11 — YOLO</option>
            <option value="1758">1758 — tinyVD (vehicle detection)</option>
            <option value="3051">3051 — tiny DMS (driver monitor)</option>
            <option value="3000">3000 — Customize single model</option>
            <option value="3001">3001 — Customize multiple models</option>
            <option value="10">10 — Generic RAW</option>
            <option value="17">17 — Generic RAW bypass pre-proc</option>
          </select>
        </div>

        <!-- Buttons -->
        <div style="display:flex;flex-direction:column;gap:8px">
          <button class="btn btn-ghost" onclick="saveModel()">Save Model (disk only)</button>
          <button class="btn btn-ok" id="btn-model-apply" onclick="applyModel()">Apply to Device + Restart</button>
        </div>

      </div>
    </div>
  </div>

  <!-- Action Bar -->
  <div class="actions">
    <span class="act-label">Actions</span>
    <button class="btn btn-primary" id="btn-compile" onclick="runAction('compile')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
      Compile
    </button>
    <button class="btn btn-ok" id="btn-deploy" onclick="runAction('deploy')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="5 12 12 5 19 12"/><line x1="12" y1="5" x2="12" y2="19"/></svg>
      Deploy to KL630
    </button>
    <button class="btn btn-warn" id="btn-bt-setup" onclick="runAction('bt_setup')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/></svg>
      BT 初始化
    </button>
    <button class="btn btn-ghost" onclick="clearLog()">Clear Log</button>
    <button class="btn btn-ghost" id="btn-autostart" onclick="runAction('autostart')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
      Write Autostart
    </button>
    <button class="btn btn-ghost" id="btn-autostart-read" onclick="runAction('autostart_read')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
      Read Autostart
    </button>
    <button class="btn btn-ghost" id="btn-mount-sd" onclick="runAction('mount_sd')">
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="2" y="6" width="20" height="14" rx="2"/><path d="M8 6V4M16 6V4M2 10h20"/></svg>
      Mount SD
    </button>
    <span id="log-status" style="margin-left:auto;font-size:11px;color:var(--muted2)"></span>
  </div>

  <!-- Terminal -->
  <div class="term-header">
    <span class="term-title">Output Log / Terminal</span>
  </div>
  <div id="log"></div>
  <datalist id="cmd-suggestions">
    <option value="ps | grep firmware">
    <option value="ps | grep kp_firmware">
    <option value="ps aux">
    <option value="killall -9 kp_firmware_host_stream">
    <option value="killall -9 rtsps">
    <option value="killall -9 kp_firmware_host_stream 2>/dev/null; killall -9 rtsps 2>/dev/null; sleep 1; rm -f /dev/shm/*">
    <option value="sleep 1">
    <option value="kill">
    <option value="ls /mnt/flash/vienna/">
    <option value="ls /mnt/flash/plus/kp_firmware/kp_firmware_0/kp_firmware/bin/">
    <option value="cat /tmp/fw.log">
    <option value="tail -30 /tmp/fw.log">
    <option value="cat /tmp/rtsp_demo.log">
    <option value="tail -30 /tmp/rtsp_demo.log">
    <option value="free">
    <option value="df -h">
    <option value="ifconfig">
    <option value="dmesg | tail -20">
    <option value="echo test">
    <option value="rm -f /dev/shm/*">
    <option value="mount /dev/mmcblk0p1 /tmp/sdcard">
    <option value="df -h /tmp/sdcard">
    <option value="ls /tmp/sdcard/events/">
    <option value="umount /tmp/sdcard">
  </datalist>
  <div style="display:flex;gap:8px;margin-top:8px">
    <span style="font-family:monospace;font-size:13px;color:var(--primary);line-height:34px;flex-shrink:0">#</span>
    <input id="term-input" type="text" list="cmd-suggestions" placeholder="Enter command… (↑↓ history, Tab suggestions)"
           style="flex:1;background:var(--term);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:7px 10px;font-family:monospace;font-size:13px;outline:none"
           onkeydown="termKeyDown(event)"
           onfocus="this.style.borderColor='var(--primary)'"
           onblur="this.style.borderColor='var(--border)'">
    <button class="btn btn-primary" onclick="sendCmd()">Run</button>
  </div>
</main>

<script>
// ── Config ────────────────────────────────────────────────────────────────────
async function loadConfig() {
  const cfg = await fetch('/api/config').then(r => r.json());
  document.getElementById('host_ip').value      = cfg.host_ip      || '';
  document.getElementById('kl630_ip').value     = cfg.kl630_ip     || '';
  document.getElementById('port').value         = cfg.port         || 8080;
  document.getElementById('docker_image').value = cfg.docker_image || 'kl630-dev';
  updateDerived(cfg);
}

function updateDerived(cfg) {
  const ip   = cfg.kl630_ip || document.getElementById('kl630_ip').value;
  const host = cfg.host_ip  || document.getElementById('host_ip').value;
  const port = cfg.port     || document.getElementById('port').value;
  document.getElementById('rtsp-url').textContent   = 'rtsp://' + ip + '/live1.sdp';
  document.getElementById('serve-base').textContent = 'http://' + host + ':' + port + '/';
  document.getElementById('deploy-cmd').textContent =
    'wget http://' + host + ':' + port + '/deploy.sh -O /tmp/deploy.sh && sh /tmp/deploy.sh';
}

async function saveConfig() {
  const cfg = {
    host_ip:      document.getElementById('host_ip').value.trim(),
    kl630_ip:     document.getElementById('kl630_ip').value.trim(),
    port:         parseInt(document.getElementById('port').value),
    docker_image: document.getElementById('docker_image').value.trim(),
  };
  await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(cfg)});
  updateDerived(cfg);
  appendLog('Config saved. deploy.sh updated → HOST_URL=http://' + cfg.host_ip + ':' + cfg.port, 'ok');
}

// ── Files ─────────────────────────────────────────────────────────────────────
async function refreshFiles() {
  const files = await fetch('/api/files').then(r => r.json());
  const ul = document.getElementById('file-list');
  if (!files.length) {
    ul.innerHTML = '<li class="file-empty">No files yet — run Compile first.</li>';
    return;
  }
  const groups = { sh: [], nef: [], o: [], other: [] };
  files.forEach(f => {
    const ext = f.name.includes('.') ? f.name.split('.').pop().toLowerCase() : '';
    if (ext === 'sh') groups.sh.push(f);
    else if (ext === 'nef') groups.nef.push(f);
    else if (ext === 'o') groups.o.push(f);
    else groups.other.push(f);
  });
  const renderItems = list => list.map(f => {
    const kb = (f.size / 1024).toFixed(1);
    return '<li class="file-item"><a class="file-name" href="/' + f.name + '" target="_blank">' + esc(f.name) + '</a><span class="file-sz">' + kb + ' KB</span></li>';
  }).join('');
  const renderGroup = (label, list, open) => {
    if (!list.length) return '';
    return '<li><details class="file-group"' + (open ? ' open' : '') + '><summary>' + label + ' <span class="fg-count">(' + list.length + ')</span></summary><ul class="file-list">' + renderItems(list) + '</ul></details></li>';
  };
  ul.innerHTML =
    renderItems(groups.other) +
    renderGroup('.sh scripts', groups.sh, true) +
    renderGroup('.nef models', groups.nef, true) +
    renderGroup('.o objects', groups.o, false);
}

// ── Status check ──────────────────────────────────────────────────────────────
async function checkStatus() {
  appendLog('Checking status...');
  const cfg = currentCfg();

  // Docker check via compile endpoint probe (lightweight)
  if (typeof EventSource !== 'undefined') {
    appendLog('Use the Compile button to verify Docker.', 'warn');
  }
  appendLog('KL630 Telnet: telnet ' + cfg.kl630_ip + '  (root / no password) — use Deploy to verify.', 'log');
}

function currentCfg() {
  return {
    host_ip:  document.getElementById('host_ip').value.trim(),
    kl630_ip: document.getElementById('kl630_ip').value.trim(),
    port:     document.getElementById('port').value,
  };
}

// ── Actions (SSE) ─────────────────────────────────────────────────────────────
let activeSSE = null;

function runAction(action) {
  if (activeSSE) { activeSSE.close(); activeSSE = null; }
  const cfg  = currentCfg();
  const urls = {
    compile:   '/api/compile/run',
    deploy:    '/api/deploy/run?ip=' + encodeURIComponent(cfg.kl630_ip) +
               '&host_ip=' + encodeURIComponent(cfg.host_ip) +
               '&port=' + encodeURIComponent(cfg.port) +
               '&out_rtsp=' + (document.getElementById('out-rtsp').checked ? 1 : 0) +
               '&out_hdmi=' + (document.getElementById('out-hdmi').checked ? 1 : 0),
    bt_setup:  '/api/bt_setup/run?ip=' + encodeURIComponent(cfg.kl630_ip),
    autostart:      '/api/autostart/write?ip=' + encodeURIComponent(cfg.kl630_ip) +
               '&out_rtsp=' + (document.getElementById('out-rtsp').checked ? 1 : 0) +
               '&out_hdmi=' + (document.getElementById('out-hdmi').checked ? 1 : 0),
    autostart_read: '/api/autostart/read?ip=' + encodeURIComponent(cfg.kl630_ip),
    mount_sd:       '/api/mount_sd?ip=' + encodeURIComponent(cfg.kl630_ip),
  };
  const labels = { compile:'Compile', deploy:'Deploy to KL630', bt_setup:'BT 初始化', autostart:'Write Autostart', autostart_read:'Read Autostart', mount_sd:'Mount SD' };

  appendLog('\\n── ' + labels[action] + ' ' + '─'.repeat(40), 'prompt');
  setLogStatus('Running...');
  setBtns(true);

  const es = new EventSource(urls[action]);
  activeSSE = es;
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') {
      es.close(); activeSSE = null;
      setBtns(false); setLogStatus('Done');
      refreshFiles();
      return;
    }
    appendLog(msg.text, msg.kind || 'log');
  };
  es.onerror = () => {
    es.close(); activeSSE = null;
    setBtns(false); setLogStatus('Connection lost');
    appendLog('SSE stream closed.', 'error');
  };
}

// ── RTSP Stream ───────────────────────────────────────────────────────────────
let streamOn = false;
let streamErrTime = 0;

function toggleStream() {
  if (!streamOn) {
    startStream();
  } else {
    stopStream();
    fetch('/api/stream/stop', {method:'POST'});
  }
}

function startStream() {
  const kl_ip = document.getElementById('kl630_ip').value.trim();
  const img   = document.getElementById('stream-img');
  const ph    = document.getElementById('stream-ph');
  const btn   = document.getElementById('btn-stream');
  const url   = '/api/stream/video?ip=' + encodeURIComponent(kl_ip) + '&t=' + Date.now();
  img.src = url;
  img.style.display = 'block';
  ph.style.display  = 'none';
  btn.textContent   = '■ Stop Stream';
  btn.className     = 'btn btn-warn btn-sm';
  document.getElementById('btn-fs').style.display = '';
  streamOn = true;
  if (document.getElementById('stdc-toggle').checked) startStdcPoll(kl_ip);
  img.onerror = () => {
    img.onerror = null;   // prevent re-entry — each failed MJPEG frame fires onerror
    const now = Date.now();
    if (now - streamErrTime > 5000) {
      appendLog('RTSP unavailable — ensure firmware is running on ' + kl_ip, 'error');
      streamErrTime = now;
    }
    fetch('/api/stream/stop', {method:'POST'});
    fetch('/api/stdc/stop', {method:'POST'});
    stopStream();
    stopStdcPoll();
  };
}

function stopStream() {
  const img = document.getElementById('stream-img');
  const ph  = document.getElementById('stream-ph');
  const btn = document.getElementById('btn-stream');
  img.src = ''; img.style.display = 'none';
  ph.style.display = 'block';
  btn.textContent = '▶ Start Stream';
  btn.className = 'btn btn-ok btn-sm';
  document.getElementById('btn-fs').style.display = 'none';
  streamOn = false;
  stopStdcPoll();
}

function copyRtsp() {
  const url = document.getElementById('rtsp-url').textContent;
  navigator.clipboard.writeText(url).then(() => appendLog('Copied: ' + url, 'ok'));
}

// ── Log helpers ───────────────────────────────────────────────────────────────
function appendLog(text, kind) {
  const log  = document.getElementById('log');
  const line = document.createElement('span');
  line.className = 'll ' + (kind || 'log');
  line.textContent = text;
  log.appendChild(line);
  log.appendChild(document.createTextNode('\\n'));
  log.scrollTop = log.scrollHeight;
}
function clearLog()       { document.getElementById('log').textContent = ''; setLogStatus(''); }
function setLogStatus(s)  { document.getElementById('log-status').textContent = s; }
function setBtns(disabled){ ['btn-compile','btn-deploy','btn-bt-setup','btn-autostart','btn-autostart-read','btn-mount-sd','btn-model-apply'].forEach(id => document.getElementById(id).disabled = disabled); }

// ── Terminal ──────────────────────────────────────────────────────────────────
// by mars
const _termHistory = [];
let   _termHistIdx = -1;

function termKeyDown(e) {
  const input = document.getElementById('term-input');
  if (e.key === 'Enter') { e.preventDefault(); sendCmd(); return; }
  if (e.key === 'ArrowUp') {
    e.preventDefault();
    if (_termHistory.length === 0) return;
    _termHistIdx = Math.min(_termHistIdx + 1, _termHistory.length - 1);
    input.value = _termHistory[_termHistIdx];
    setTimeout(() => input.setSelectionRange(input.value.length, input.value.length), 0);
    return;
  }
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    if (_termHistIdx <= 0) { _termHistIdx = -1; input.value = ''; return; }
    _termHistIdx--;
    input.value = _termHistory[_termHistIdx];
    return;
  }
}

function sendCmd() {
  const input = document.getElementById('term-input');
  const cmd   = input.value.trim();
  if (!cmd) return;
  const ip = document.getElementById('kl630_ip').value.trim();
  // Push to history (deduplicate: remove existing entry, prepend)
  const existing = _termHistory.indexOf(cmd);
  if (existing !== -1) _termHistory.splice(existing, 1);
  _termHistory.unshift(cmd);
  _termHistIdx = -1;
  appendLog('# ' + cmd, 'prompt');
  input.value = '';
  const url = '/api/terminal/exec?cmd=' + encodeURIComponent(cmd) + '&ip=' + encodeURIComponent(ip);
  const es  = new EventSource(url);
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') { es.close(); input.focus(); return; }
    appendLog(msg.text, msg.kind);
  };
  es.onerror = () => { es.close(); appendLog('Connection lost.', 'error'); };
}
function esc(t)           { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

// ── INI Settings ──────────────────────────────────────────────────────────────
async function loadIni() {
  const d = await fetch('/api/ini').then(r => r.json());
  const fec = d.fec_mode || 0;
  const tog = document.getElementById('fec-toggle');
  const sel = document.getElementById('fec-mode');
  const lbl = document.getElementById('fec-state-label');
  tog.checked = fec > 0;
  sel.disabled = fec === 0;
  lbl.textContent = fec > 0 ? 'ON' : 'OFF';
  if (fec > 0) sel.value = String(fec);
  document.getElementById('fec-app-type').value = String(d.initial_fec_app_type || 0);
  document.getElementById('eis-toggle').checked = (d.eis_enable || 0) > 0;
  document.getElementById('draw-box-toggle').checked = (d.draw_box_enable || 0) > 0;
  document.getElementById('verbose-log-toggle').checked = (d.verbose_log || 0) > 0;
  document.getElementById('out-hdmi').checked = (d.voc_enable || 0) > 0;
}

function onFecToggle() {
  const on  = document.getElementById('fec-toggle').checked;
  const sel = document.getElementById('fec-mode');
  const lbl = document.getElementById('fec-state-label');
  sel.disabled = !on;
  lbl.textContent = on ? 'ON' : 'OFF';
}

function _fecParams() {
  const on = document.getElementById('fec-toggle').checked;
  return {
    fec_mode: on ? parseInt(document.getElementById('fec-mode').value) : 0,
    app_type: parseInt(document.getElementById('fec-app-type').value),
    eis:         document.getElementById('eis-toggle').checked ? 1 : 0,
    draw_box:    document.getElementById('draw-box-toggle').checked ? 1 : 0,
    verbose_log:    document.getElementById('verbose-log-toggle').checked ? 1 : 0,
    out_rtsp:    document.getElementById('out-rtsp').checked ? 1 : 0,
    out_hdmi:    document.getElementById('out-hdmi').checked ? 1 : 0,
    kl_ip:       document.getElementById('kl630_ip').value.trim(),
  };
}

function saveIni() {
  const p = _fecParams();
  const url = '/api/ini/apply?fec_mode=' + p.fec_mode + '&app_type=' + p.app_type + '&eis=' + p.eis + '&draw_box=' + p.draw_box + '&verbose_log=' + p.verbose_log + '&out_rtsp=' + p.out_rtsp + '&out_hdmi=' + p.out_hdmi + '&device=0';
  const es  = new EventSource(url);
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') { es.close(); return; }
    appendLog(msg.text, msg.kind);
  };
  document.getElementById('ini-saved-badge').style.display = 'inline';
  setTimeout(() => document.getElementById('ini-saved-badge').style.display = 'none', 3000);
}

function applyIni() {
  if (activeSSE) { activeSSE.close(); activeSSE = null; }
  const p = _fecParams();
  // Step 1: save INI locally (device=0), then full deploy
  const saveUrl = '/api/ini/apply?fec_mode=' + p.fec_mode + '&app_type=' + p.app_type +
                  '&eis=' + p.eis + '&draw_box=' + p.draw_box +
                  '&verbose_log=' + p.verbose_log +
                  '&out_rtsp=' + p.out_rtsp + '&out_hdmi=' + p.out_hdmi + '&device=0';
  appendLog('\\n── Apply FEC + Deploy to Device ──────────────────────────────────────', 'prompt');
  setLogStatus('Saving...');
  setBtns(true);
  const es = new EventSource(saveUrl);
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') { es.close(); runAction('deploy'); return; }
    appendLog(msg.text, msg.kind);
  };
  es.onerror = () => { es.close(); setBtns(false); setLogStatus(''); };
}

// ── STDC AI Overlay ───────────────────────────────────────────────────────────
let stdcPollId = null;
let stdcActive = false;
const STDC_COLORS = {
  road:'#9ca3af', car:'#ef4444', person:'#f97316',
  grass:'#86efac', greenery:'#22c55e', tree:'#15803d',
  pond:'#60a5fa', bunker:'#94a3b8'
};
const STDC_ORDER = ['road','car','person','grass','greenery','tree','pond','bunker'];

function _stdcVisibility() {
  const fs  = !!document.fullscreenElement;
  const on  = stdcActive && document.getElementById('stdc-toggle').checked;
  // ROI SVG — always shown on stream when AI on
  document.getElementById('stdc-roi-svg').style.display  = on ? '' : 'none';
  // Stats panel below stream — normal mode only
  document.getElementById('stdc-panel').style.display    = on && !fs ? '' : 'none';
  // Fullscreen overlay — fullscreen only
  document.getElementById('stdc-overlay').style.display  = on && fs ? 'flex' : 'none';
}

function startStdcPoll(ip) {
  fetch('/api/stdc/start', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip})});
  stdcActive = true;
  _stdcVisibility();
  if (stdcPollId) clearInterval(stdcPollId);
  stdcPollId = setInterval(fetchStdcStats, 500);
}

function stopStdcPoll() {
  fetch('/api/stdc/stop', {method:'POST'});
  if (stdcPollId) { clearInterval(stdcPollId); stdcPollId = null; }
  stdcActive = false;
  _stdcVisibility();
}

function onStdcToggle(checked) {
  _stdcVisibility();
  if (streamOn && checked && !stdcActive)
    startStdcPoll(document.getElementById('kl630_ip').value.trim());
  if (!checked && stdcActive) stopStdcPoll();
}

function toggleFullscreen() {
  const box = document.getElementById('stream-box');
  if (!document.fullscreenElement) box.requestFullscreen();
  else document.exitFullscreen();
}

document.addEventListener('fullscreenchange', _stdcVisibility);

async function fetchStdcStats() {
  try {
    const d = await fetch('/api/stdc/stats').then(r => r.json());
    if (Object.keys(d).length) _renderStdc(d);
  } catch(e) {}
}

function _statusHtml(status, grassTime, isWarn, small) {
  const sz = small ? '10px' : '11px';
  const px = small ? '2px 7px' : '3px 10px';
  if (status === 'ON GRASS') {
    const sfx = grassTime ? ' ' + grassTime.toFixed(1) + 's' : '';
    const bg  = isWarn ? 'rgba(220,38,38,0.9)' : 'rgba(234,88,12,0.9)';
    return '<span style="padding:' + px + ';border-radius:4px;font-size:' + sz + ';font-weight:700;color:#fff;background:' + bg + ';white-space:nowrap">ON GRASS' + sfx + (isWarn ? ' ⚠' : '') + '</span>';
  }
  if (status === 'ON ROAD')
    return '<span style="padding:' + px + ';border-radius:4px;font-size:' + sz + ';font-weight:700;color:#fff;background:rgba(22,163,74,0.9);white-space:nowrap">ON ROAD</span>';
  return '';
}

function _renderStdc(d) {
  const status    = d.status || '';
  const grassTime = d.grass_time;
  const isWarn    = d.grass_warning;
  const warns     = d.warns || [];
  const fi        = d.frame !== undefined
    ? 'frame ' + d.frame + ' · ' + (d.moving ? 'moving' : 'still') + ' · diff ' + (d.diff || 0).toFixed(1)
    : '';

  // ── Fullscreen overlay ────────────────────────────────────────────────────
  const barsFs = document.getElementById('stdc-bars-fs');
  barsFs.innerHTML =
    '<div style="font-size:9px;color:#475569;font-weight:700;text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px">Segmentation</div>' +
    STDC_ORDER.map(cls => {
      const pct = d[cls] || 0;
      const col = STDC_COLORS[cls];
      return '<div style="display:flex;align-items:center;gap:4px;margin-bottom:3px">' +
        '<span style="font-size:10px;color:#94a3b8;width:52px;text-align:right;flex-shrink:0">' + cls + '</span>' +
        '<div style="width:70px;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;flex-shrink:0">' +
          '<div style="width:' + Math.min(pct, 100) + '%;height:100%;background:' + col + ';border-radius:3px"></div>' +
        '</div>' +
        '<span style="font-size:10px;color:#e2e8f0;width:28px">' + pct.toFixed(0) + '%</span>' +
      '</div>';
    }).join('');
  document.getElementById('stdc-status-fs').innerHTML = _statusHtml(status, grassTime, isWarn, false);
  document.getElementById('stdc-warns-fs').innerHTML = warns.map(w =>
    '<div style="background:rgba(220,38,38,0.85);color:#fff;padding:2px 8px;border-radius:3px;font-size:10px;font-weight:700;margin-top:2px;text-align:right">⚠ ' + esc(w) + '</div>'
  ).join('');
  document.getElementById('stdc-fi-fs').textContent = fi;

  // ── Below-stream panel ────────────────────────────────────────────────────
  document.getElementById('stdc-status-p').innerHTML = _statusHtml(status, grassTime, isWarn, true);
  const barsP = document.getElementById('stdc-panel-bars');
  barsP.innerHTML = STDC_ORDER.map(cls => {
    const pct = d[cls] || 0;
    const col = STDC_COLORS[cls];
    return '<div>' +
      '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">' +
        '<span style="font-size:11px;color:var(--muted2);text-transform:capitalize">' + cls + '</span>' +
        '<span style="font-size:11px;font-family:monospace;color:var(--text)">' + pct.toFixed(1) + '%</span>' +
      '</div>' +
      '<div style="height:6px;border-radius:3px;background:var(--border);overflow:hidden">' +
        '<div style="height:100%;width:' + Math.min(pct,100) + '%;background:' + col + ';border-radius:3px;transition:width 0.3s"></div>' +
      '</div>' +
    '</div>';
  }).join('');
  document.getElementById('stdc-warns-p').innerHTML = warns.map(w =>
    '<span style="background:rgba(220,38,38,0.85);color:#fff;padding:1px 6px;border-radius:3px;font-size:9px;font-weight:700">⚠ ' + esc(w.split(' ')[0]) + '</span>'
  ).join('');
  document.getElementById('stdc-fi-p').textContent = fi;

  // ── ROI colour update (collision → red) ───────────────────────────────────
  const colStroke = (d.warns || []).some(w => w.startsWith('COLLISION'))
    ? 'rgba(239,68,68,0.9)' : 'rgba(59,130,246,0.85)';
  document.getElementById('roi-col').setAttribute('stroke', colStroke);
}

// ── Collapsible cards ─────────────────────────────────────────────────────────
function toggleCard(bodyId, arrowId) {
  const body  = document.getElementById(bodyId);
  const arrow = document.getElementById(arrowId);
  const open  = body.style.display !== 'none';
  body.style.display = open ? 'none' : '';
  arrow.textContent  = open ? '▶' : '▼';
}

// ── Model Settings ────────────────────────────────────────────────────────────
// Default ModelId per JobId (from SDK / readme.txt)
const JOB_DEFAULT_MODEL_ID = {
  200:  32769,  // STDC segmentation
  11:   211,    // YOLO (models_630_211.nef)
  1758: 32768,  // tinyVD — factory preset range
  3051: 32768,  // tiny DMS — factory preset range
  3000: 32768,  // Customize single
  3001: 32768,  // Customize multiple
  10:   0,      // Generic RAW — no fixed model id
  17:   0,      // Generic RAW bypass
};

function onJobIdChange() {
  const jobId = parseInt(document.getElementById('job-id').value);
  if (JOB_DEFAULT_MODEL_ID[jobId] !== undefined) {
    document.getElementById('model-id').value = JOB_DEFAULT_MODEL_ID[jobId];
  }
}

async function loadModel() {
  const d = await fetch('/api/model').then(r => r.json());
  document.getElementById('model-id').value = d.model_id || 32769;
  document.getElementById('job-id').value   = d.job_id   || 200;
  await refreshNefList(d.model_path);
}

async function refreshNefList(currentPath) {
  const files = await fetch('/api/model/list').then(r => r.json());
  const sel = document.getElementById('model-nef-select');
  if (!files.length) {
    sel.innerHTML = '<option value="">No .nef files — upload one</option>';
    return;
  }
  sel.innerHTML = files.map(f => {
    const path = 'nef/' + f.name;
    const mb   = (f.size / 1048576).toFixed(1);
    const sel  = (currentPath === path || currentPath === f.name) ? ' selected' : '';
    return `<option value="${esc(path)}"${sel}>${esc(f.name)} (${mb} MB)</option>`;
  }).join('');
}

async function uploadNef(input) {
  const file = input.files[0];
  if (!file) return;
  const status = document.getElementById('upload-status');
  status.style.color = 'var(--muted2)';
  status.textContent = 'Uploading ' + file.name + '...';
  const fd = new FormData();
  fd.append('file', file);
  try {
    const r = await fetch('/api/model/upload', {method: 'POST', body: fd});
    const d = await r.json();
    if (d.ok) {
      status.style.color = 'var(--ok)';
      status.textContent = 'Uploaded: ' + d.filename + ' (' + (d.size / 1048576).toFixed(1) + ' MB)';
      await refreshNefList('nef/' + d.filename);
      document.getElementById('model-nef-select').value = 'nef/' + d.filename;
    } else {
      status.style.color = 'var(--err)';
      status.textContent = 'Upload failed: ' + (d.error || 'unknown');
    }
  } catch(e) {
    status.style.color = 'var(--err)';
    status.textContent = 'Upload error: ' + e;
  }
  input.value = '';
}

function _modelParams() {
  return {
    model_path: document.getElementById('model-nef-select').value,
    model_id:   parseInt(document.getElementById('model-id').value) || 32769,
    job_id:     parseInt(document.getElementById('job-id').value)   || 200,
    kl_ip:      document.getElementById('kl630_ip').value.trim(),
    out_rtsp:   document.getElementById('out-rtsp').checked ? 1 : 0,
    out_hdmi:   document.getElementById('out-hdmi').checked ? 1 : 0,
  };
}

function saveModel() {
  const p   = _modelParams();
  const url = '/api/model/apply?model_path=' + encodeURIComponent(p.model_path) +
              '&model_id=' + p.model_id + '&job_id=' + p.job_id + '&device=0';
  const es  = new EventSource(url);
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') { es.close(); return; }
    appendLog(msg.text, msg.kind);
  };
  document.getElementById('model-saved-badge').style.display = 'inline';
  setTimeout(() => document.getElementById('model-saved-badge').style.display = 'none', 3000);
}

function applyModel() {
  if (activeSSE) { activeSSE.close(); activeSSE = null; }
  const p = _modelParams();
  // Step 1: save model settings locally (device=0), then full deploy
  const saveUrl = '/api/model/apply?model_path=' + encodeURIComponent(p.model_path) +
                  '&model_id=' + p.model_id + '&job_id=' + p.job_id + '&device=0';
  appendLog('\\n── Apply Model + Deploy to Device ──────────────────────────────────────', 'prompt');
  setLogStatus('Saving...');
  setBtns(true);
  const es = new EventSource(saveUrl);
  es.onmessage = e => {
    const msg = JSON.parse(e.data);
    if (msg.text === '__DONE__') { es.close(); runAction('deploy'); return; }
    appendLog(msg.text, msg.kind);
  };
  es.onerror = () => { es.close(); setBtns(false); setLogStatus(''); };
}

// ── Init ──────────────────────────────────────────────────────────────────────
loadConfig().then(() => refreshFiles());
loadIni();
loadModel();
appendLog('KL630 Control Panel ready.', 'ok');
</script>
</body>
</html>"""

# ── Entry point ───────────────────────────────────────────────────────────────
def _ensure_docker_image_bg():
    """Background thread: check Docker + build image on startup so first compile is fast."""
    cfg = load_config()
    docker_image = cfg.get("docker_image", "kl630-dev")
    if shutil.which("docker") is None:
        print("WARNING: docker not found in PATH — compile will fail.")
        return
    r = subprocess.run(["docker", "image", "inspect", docker_image], capture_output=True)
    if r.returncode == 0:
        print(f"[Docker] Image '{docker_image}' ready.")
        return
    dockerfile = SCRIPT_DIR / "Dockerfile"
    if not dockerfile.exists():
        print(f"WARNING: Dockerfile not found, cannot pre-build image.")
        return
    print(f"[Docker] Image '{docker_image}' not found — building now (background)...")
    r2 = subprocess.run(["docker", "build", "-t", docker_image, str(SCRIPT_DIR)])
    if r2.returncode == 0:
        print(f"[Docker] Image '{docker_image}' built successfully.")
    else:
        print(f"[Docker] Image build FAILED (exit {r2.returncode}).")


if __name__ == "__main__":  # by mars
    import argparse
    parser = argparse.ArgumentParser(description="KL630 Web Control Panel")
    parser.add_argument("--port", type=int, default=8080, help="Web UI + file server port")
    parser.add_argument("--host", default="0.0.0.0")
    args = parser.parse_args()

    if not HAS_CV2:
        print("WARNING: opencv-python not installed — RTSP stream preview disabled.")
        print("         Run:  pip install -r requirements.txt")

    prepare_build_dir()

    # Pre-check Docker image in background so first compile doesn't wait
    threading.Thread(target=_ensure_docker_image_bg, daemon=True).start()

    print(f"\nKL630 Control Panel  ->  http://localhost:{args.port}/\n")
    app.run(host=args.host, port=args.port, debug=False, threaded=True)
