包含从项目创建至今的全部代码首次入库: 核心框架: - TaskQueue(SQLite,6 种状态,原子 dequeue) - Executor(asyncio 并发,semaphore 限流,agent_role 路由) - Scheduler(APScheduler,三队列独立调度) - Watchdog(超时检测 + 优先级防饥饿) - Scanner(项目扫描,README/TODO/CLAUDE.md 提取) Agent 体系(20+ 角色): - 项目交付组:architect/developer/tester/productizer - 基础技术组:base-architect/validator/hw/os/kernel/lowlevel/system-tester - 算法组:algo-antishake/position/nav - 洞察组:planner/vision-analyst/media-producer - 市场组:market-pm/sport/elder/safety - 组织层:boss/group-leader/senior-dev/ops - 平台扩展:nrf-dev/esp32-dev - 三专项调研:rtp-researcher/net-researcher/kernel-analyzer 工具层: - ProjectMemory(goals/facts/history SQLite) - VersionPipeline(并行版本流水线 + 反幻觉门控) - EventBus(Redis Streams,5 consumer groups) - DailyReport(append-only 日报 + Boss 决策视图) - AgentScorer(4 维评分:完成度/质量/自主性/协作) - DeviceAgent(SSH rsync + 板端执行) - EnvCollector(本机+SSH 环境采集) - ArchVersion(快照/优化/promote/rollback) Dashboard(React + Vite + Tailwind): - 看板/项目/配置/洞察/日报/Visual 六标签页 - WebSocket 实时更新 - Markdown 渲染(react-markdown + remark-gfm) 测试:314 passed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
|