gf_ai_box/web_serve.py
2026-04-12 09:40:41 +00:00

2236 lines
100 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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: first-time ISP setup via Telnet (SSE) ────────────────────────────────
@app.route("/api/setup/run")
def api_setup_run():
cfg = load_config()
kl_ip = request.args.get("ip", cfg["kl630_ip"])
bd = BIN_DIR_DEVICE
awb = f"{bd}/Resource/AWB/AutoWhiteBalance.ini"
isp0 = f"{bd}/Resource/ISP/0/pqtable_ispe_Config.cfg"
isp1 = f"{bd}/Resource/ISP/1/pqtable_ispe_Config.cfg"
commands = [
f"sed -i 's/dwStatisticsSrcType = 0/dwStatisticsSrcType = 2/' {awb}",
f"sed -i 's/bGTREnable = 0/bGTREnable = 1/' {isp0}",
f"sed -i 's/bGTREnable = 0/bGTREnable = 1/' {isp1}",
f"grep dwStatisticsSrcType {awb}",
f"grep bGTREnable {isp0}",
]
def generate():
yield sse(f"Connecting to {kl_ip}:23 via Telnet...")
try:
tn = _telnet_connect(kl_ip)
_drain_shell(tn)
yield sse("Connected.", "ok")
for cmd in commands:
yield sse(f"$ {cmd}", "prompt")
for ln in _telnet_run(tn, cmd):
yield sse(ln, "ok")
tn.close()
yield sse("One-time ISP setup complete. Settings written to flash.", "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-setup" onclick="runAction('setup')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 0-14.14 0M4.93 19.07a10 10 0 0 0 14.14 0"/></svg>
First-time ISP Setup
</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),
setup: '/api/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', setup:'First-time ISP Setup', 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-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)