208 lines
7.2 KiB
Python
208 lines
7.2 KiB
Python
"""EdgeValidatorService: 部署在 RK3588 上,为开发机 agents 提供 HTTP 接口。
|
||||
|
|
|
|||
|
|
启动:uvicorn main:app --host 0.0.0.0 --port 8899
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import base64
|
|||
|
|
import glob
|
|||
|
|
import re
|
|||
|
|
import subprocess
|
|||
|
|
import time
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
from fastapi import FastAPI, HTTPException
|
|||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
|
|||
|
|
app = FastAPI(title="EdgeValidatorService", version="1.0.0")
|
|||
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
|||
|
|
allow_methods=["*"], allow_headers=["*"])
|
|||
|
|
|
|||
|
|
# 设备配置(本机 + 接入的 MCU)
|
|||
|
|
SERIAL_DEVICES: dict[str, dict] = {}
|
|||
|
|
|
|||
|
|
# 推理日志路径
|
|||
|
|
LOG_PATHS = {
|
|||
|
|
"default": "/tmp/infer.log",
|
|||
|
|
"yolo": "/tmp/yolo_infer.log",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RunTestRequest(BaseModel):
|
|||
|
|
cmd: str
|
|||
|
|
cwd: str = "/home/pi/Desktop"
|
|||
|
|
timeout: int = 120
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FlashRequest(BaseModel):
|
|||
|
|
firmware_b64: str
|
|||
|
|
offset: str = "0x0"
|
|||
|
|
port: str = "/dev/ttyUSB0"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 端点 ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
@app.get("/health")
|
|||
|
|
def health():
|
|||
|
|
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/devices")
|
|||
|
|
def list_devices():
|
|||
|
|
"""列出识别到的串口设备"""
|
|||
|
|
serial_ports = glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*")
|
|||
|
|
return {"rk3588": "linux/ssh", "serial_ports": serial_ports,
|
|||
|
|
"configured_mcus": list(SERIAL_DEVICES.keys())}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/metrics/{device_name}")
|
|||
|
|
def get_metrics(device_name: str):
|
|||
|
|
if device_name == "rk3588":
|
|||
|
|
return _linux_metrics()
|
|||
|
|
if device_name in SERIAL_DEVICES:
|
|||
|
|
return _mcu_metrics(SERIAL_DEVICES[device_name])
|
|||
|
|
raise HTTPException(404, f"未知设备: {device_name}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/run-test/{device_name}")
|
|||
|
|
def run_test(device_name: str, req: RunTestRequest):
|
|||
|
|
if device_name == "rk3588":
|
|||
|
|
return _run_local(req)
|
|||
|
|
if device_name in SERIAL_DEVICES:
|
|||
|
|
return _run_serial(SERIAL_DEVICES[device_name], req)
|
|||
|
|
raise HTTPException(404, f"未知设备: {device_name}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/screenshot")
|
|||
|
|
def screenshot():
|
|||
|
|
"""截取 RK3588 屏幕(需要 scrot 或 ffmpeg)"""
|
|||
|
|
import tempfile
|
|||
|
|
import os
|
|||
|
|
tmp = tempfile.mktemp(suffix=".png")
|
|||
|
|
r = subprocess.run(["scrot", tmp], capture_output=True, timeout=10)
|
|||
|
|
if r.returncode != 0:
|
|||
|
|
r = subprocess.run(
|
|||
|
|
f"cat /dev/fb0 | ffmpeg -vcodec rawvideo -f rawvideo "
|
|||
|
|
f"-pix_fmt rgb32 -s 1920x1080 -i - -f image2 -vcodec png {tmp} -y",
|
|||
|
|
shell=True, capture_output=True, timeout=15,
|
|||
|
|
)
|
|||
|
|
if r.returncode != 0:
|
|||
|
|
raise HTTPException(500, "截图失败,请确认 scrot 或 ffmpeg 已安装")
|
|||
|
|
with open(tmp, "rb") as f:
|
|||
|
|
img_b64 = base64.b64encode(f.read()).decode()
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
return {"image_b64": img_b64, "format": "png"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/logs/{device_name}/{n}")
|
|||
|
|
def get_logs(device_name: str, n: int = 50):
|
|||
|
|
log_path = LOG_PATHS.get(device_name, LOG_PATHS["default"])
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(["tail", f"-{n}", log_path],
|
|||
|
|
capture_output=True, text=True, timeout=5)
|
|||
|
|
return {"lines": r.stdout.splitlines(), "file": log_path}
|
|||
|
|
except Exception as e:
|
|||
|
|
return {"lines": [], "error": str(e), "file": log_path}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 内部函数 ──────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def _run_local(req: RunTestRequest) -> dict:
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
r = subprocess.run(
|
|||
|
|
req.cmd, shell=True, capture_output=True, text=True,
|
|||
|
|
cwd=req.cwd, timeout=req.timeout,
|
|||
|
|
)
|
|||
|
|
duration = time.monotonic() - t0
|
|||
|
|
stdout = r.stdout
|
|||
|
|
pass_ = r.returncode == 0 and not re.search(r"\b(FAILED|ERROR)\b", stdout + r.stderr)
|
|||
|
|
return {"rc": r.returncode, "stdout": stdout, "stderr": r.stderr,
|
|||
|
|
"duration_s": round(duration, 2), "pass_": pass_}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_serial(dev: dict, req: RunTestRequest) -> dict:
|
|||
|
|
try:
|
|||
|
|
import serial # type: ignore
|
|||
|
|
except ImportError:
|
|||
|
|
return {"rc": 1, "stdout": "", "stderr": "pyserial 未安装",
|
|||
|
|
"duration_s": 0, "pass_": False}
|
|||
|
|
t0 = time.monotonic()
|
|||
|
|
lines: list[str] = []
|
|||
|
|
try:
|
|||
|
|
ser = serial.Serial(dev["port"], dev.get("baud", 115200), timeout=req.timeout)
|
|||
|
|
ser.write((req.cmd + "\r\n").encode())
|
|||
|
|
deadline = time.monotonic() + req.timeout
|
|||
|
|
while time.monotonic() < deadline:
|
|||
|
|
line = ser.readline().decode(errors="replace").strip()
|
|||
|
|
if line:
|
|||
|
|
lines.append(line)
|
|||
|
|
if line.startswith(("PASS", "FAIL", "ERROR")):
|
|||
|
|
break
|
|||
|
|
ser.close()
|
|||
|
|
except Exception as e:
|
|||
|
|
return {"rc": 1, "stdout": "\n".join(lines), "stderr": str(e),
|
|||
|
|
"duration_s": time.monotonic() - t0, "pass_": False}
|
|||
|
|
stdout = "\n".join(lines)
|
|||
|
|
rc = 0 if any(l.startswith("PASS") for l in lines) else 1
|
|||
|
|
return {"rc": rc, "stdout": stdout, "stderr": "",
|
|||
|
|
"duration_s": round(time.monotonic() - t0, 2), "pass_": rc == 0}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _linux_metrics() -> dict:
|
|||
|
|
m: dict = {"device": "rk3588", "timestamp": datetime.utcnow().isoformat()}
|
|||
|
|
try:
|
|||
|
|
with open("/proc/stat") as f:
|
|||
|
|
parts = f.readline().split()
|
|||
|
|
user, nice, sys_, idle = int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4])
|
|||
|
|
total = user + nice + sys_ + idle
|
|||
|
|
m["cpu_pct"] = round(100 * (user + sys_) / total, 1) if total else 0
|
|||
|
|
except Exception:
|
|||
|
|
m["cpu_pct"] = None
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=3)
|
|||
|
|
for line in r.stdout.splitlines():
|
|||
|
|
if line.startswith("Mem:"):
|
|||
|
|
parts = line.split()
|
|||
|
|
m["mem_used_mb"] = float(parts[2])
|
|||
|
|
break
|
|||
|
|
except Exception:
|
|||
|
|
m["mem_used_mb"] = None
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(
|
|||
|
|
"tail -10 /tmp/infer.log 2>/dev/null | grep -oP 'fps=\\K[0-9.]+' | tail -1",
|
|||
|
|
shell=True, capture_output=True, text=True, timeout=3,
|
|||
|
|
)
|
|||
|
|
raw = r.stdout.strip()
|
|||
|
|
m["fps"] = float(raw) if raw else None
|
|||
|
|
except Exception:
|
|||
|
|
m["fps"] = None
|
|||
|
|
return m
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mcu_metrics(dev: dict) -> dict:
|
|||
|
|
m: dict = {"device": dev.get("name", "mcu"),
|
|||
|
|
"timestamp": datetime.utcnow().isoformat()}
|
|||
|
|
try:
|
|||
|
|
import serial # type: ignore
|
|||
|
|
ser = serial.Serial(dev["port"], dev.get("baud", 115200), timeout=3)
|
|||
|
|
ser.write(b"GET_METRICS\r\n")
|
|||
|
|
for _ in range(20):
|
|||
|
|
line = ser.readline().decode(errors="replace").strip()
|
|||
|
|
if line.startswith("METRICS:"):
|
|||
|
|
for kv in line[8:].split():
|
|||
|
|
k, _, v = kv.partition("=")
|
|||
|
|
if k == "fps":
|
|||
|
|
m["fps"] = float(v)
|
|||
|
|
elif k == "latency_ms":
|
|||
|
|
m["inference_latency_ms"] = float(v)
|
|||
|
|
elif k == "ram_kb":
|
|||
|
|
m["ram_used_kb"] = int(v)
|
|||
|
|
break
|
|||
|
|
ser.close()
|
|||
|
|
except Exception as e:
|
|||
|
|
m["error"] = str(e)
|
|||
|
|
return m
|