2301 lines
103 KiB
Python
2301 lines
103 KiB
Python
#!/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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
// ── 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)
|