Rename customs-tablet-frontend to public-frontend and add new features

- Rename customs-tablet-frontend/ to public-frontend/ for broader scope
- Add new pages: customs, inspection with camera integration
- Add new services: apiClient.ts, backendApi.ts, normalizers.ts
- Add CameraFrame component for real-time video streaming
- Add scan_fixer module with clock_publisher and timestamp fix utilities
- Update startup scripts to support new frontend structure
- Update arm_server configuration and service files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:18:20 +08:00
parent 083d12016a
commit 1429442dbd
49 changed files with 2758 additions and 2141 deletions
+66 -83
View File
@@ -1,7 +1,7 @@
"""
机械臂服务端 - 机械臂端主程序
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (630 Socket API)
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot)
同时通过 ffmpeg 提供 HTTP 视频流
"""
import socket
@@ -11,6 +11,8 @@ import logging
import os
import sys
import subprocess
import io
from PIL import Image
from flask import Flask, Response, jsonify
from werkzeug.serving import make_server
@@ -41,6 +43,19 @@ _frame_cond = threading.Condition()
_latest_frame = None
_latest_frame_ts = 0.0
_stop_ffmpeg_reader = threading.Event()
_invalid_count = 0
_MAX_INVALID = 30
_MAX_BUF_SIZE = 2 * 1024 * 1024
_elephant = None
def _validate_jpeg(data):
"""验证 JPEG 数据是否有效。"""
try:
Image.open(io.BytesIO(data)).verify()
return True
except Exception:
return False
def _stop_ffmpeg():
@@ -57,8 +72,8 @@ def _stop_ffmpeg():
def _frame_reader():
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,并缓存最新一帧。"""
global _ffmpeg_proc, _latest_frame, _latest_frame_ts
"""从 ffmpeg 的连续 MJPEG 输出中解析、校验并缓存最新一帧。"""
global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
buf = b""
while not _stop_ffmpeg_reader.is_set():
proc = _ffmpeg_proc
@@ -72,6 +87,11 @@ def _frame_reader():
time.sleep(0.02)
continue
buf += chunk
if len(buf) > _MAX_BUF_SIZE:
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
buf = b""
_stop_ffmpeg()
continue
while True:
start = buf.find(b"\xff\xd8")
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
@@ -83,15 +103,24 @@ def _frame_reader():
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
with _frame_cond:
_latest_frame = frame
_latest_frame_ts = time.time()
_frame_cond.notify_all()
if _validate_jpeg(frame):
with _frame_cond:
_latest_frame = frame
_latest_frame_ts = time.time()
_frame_cond.notify_all()
_invalid_count = 0
else:
_invalid_count += 1
if _invalid_count >= _MAX_INVALID:
logger.error(f"连续 {_MAX_INVALID} 帧无效,摄像头可能掉线,重启 ffmpeg")
_stop_ffmpeg()
_invalid_count = 0
break
def _ensure_ffmpeg():
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
global _ffmpeg_proc, _ffmpeg_thread
global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
with _ffmpeg_lock:
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return
@@ -100,6 +129,7 @@ def _ensure_ffmpeg():
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate()
_stop_ffmpeg_reader.clear()
_invalid_count = 0
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen(
@@ -107,11 +137,11 @@ def _ensure_ffmpeg():
"ffmpeg",
"-f", "v4l2",
"-input_format", "mjpeg",
"-framerate", "12",
"-video_size", "1280x720",
"-framerate", "8",
"-video_size", "640x480",
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-vf", "rotate=PI",
"-q:v", "4",
"-fflags", "nobuffer",
"-analyzeduration", "0",
"-f", "mjpeg",
"-"
],
@@ -166,17 +196,22 @@ def arm_camera_status():
"""摄像头状态"""
global _ffmpeg_proc
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
return jsonify({"opened": running, "frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None})
return jsonify({
"opened": running,
"frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None,
"invalid_count": _invalid_count,
})
@arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart():
"""重启视频流"""
global _latest_frame, _latest_frame_ts
global _latest_frame, _latest_frame_ts, _invalid_count
_stop_ffmpeg()
with _frame_cond:
_latest_frame = None
_latest_frame_ts = 0.0
_invalid_count = 0
_ensure_ffmpeg()
return jsonify({"ok": True})
@@ -193,72 +228,18 @@ def arm_camera_snapshot():
logger.warning("snapshot failed: no cached frame")
return "", 500
# ========== RoboFlow 630 Socket API 客户端 ==========
class RoboFlowClient:
"""通过 Socket 连接 RoboFlow 630 机械臂控制盒"""
def __init__(self, host: str = "127.0.0.1", port: int = 5001, timeout: float = 10):
self.host = host
self.port = port
self.timeout = timeout
self._sock: socket.socket = None
def connect(self) -> bool:
try:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(self.timeout)
self._sock.connect((self.host, self.port))
logger.info(f"已连接到 RoboFlow {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"连接 RoboFlow 失败: {e}")
return False
def send_recv(self, cmd: str) -> str:
"""发送命令并等待响应"""
if not self._sock:
raise ConnectionError("未连接到 RoboFlow")
try:
self._sock.sendall((cmd + "\n").encode("utf-8"))
resp = self._sock.recv(4096).decode("utf-8").strip()
return resp
except socket.timeout:
return "ERROR: timeout"
except Exception as e:
return f"ERROR: {e}"
def close(self):
if self._sock:
self._sock.close()
self._sock = None
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.close()
# ========== TCP 服务器 - 接收 AGV 指令 ==========
class AGVCommandServer:
"""TCP 服务器,接收 AGV 发来的指令"""
"""TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
def __init__(self, host: str = "0.0.0.0", port: int = 5002):
def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
self.host = host
self.port = port
self._sock: socket.socket = None
self._running = False
self.roboflow: RoboFlowClient = None
self._connect_roboflow()
def _connect_roboflow(self):
self.roboflow = RoboFlowClient()
if self.roboflow.connect():
logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)")
else:
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
self._elephant = elephant
if self._elephant is None:
logger.warning("ElephantRobot 实例为空,命令将返回错误")
def start(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -312,10 +293,10 @@ class AGVCommandServer:
logger.info("AGV 客户端已断开")
def _execute_command(self, cmd: str) -> str:
if not self.roboflow or not self.roboflow._sock:
return f"ERROR: RoboFlow not connected"
if self._elephant is None:
return "ERROR: Robot not initialized"
try:
return self.roboflow.send_recv(cmd)
return self._elephant.send_command(cmd)
except Exception as e:
return f"ERROR: {e}"
@@ -326,16 +307,13 @@ class AGVCommandServer:
self._sock.close()
except:
pass
if self.roboflow:
self.roboflow.close()
logger.info("机械臂服务端已停止")
# ========== 入口 ==========
import time
def power_on_arm(max_retries: int = 5) -> bool:
"""通过 ElephantRobot 给机械臂上电并激活(带重试)"""
global _elephant
from pymycobot import ElephantRobot
for attempt in range(1, max_retries + 1):
@@ -354,6 +332,7 @@ def power_on_arm(max_retries: int = 5) -> bool:
logger.info("start_robot 指令已发送,等待5秒...")
time.sleep(5)
logger.info("✅ 机械臂上电+激活 全部完成")
_elephant = el
return True
except Exception as e:
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
@@ -372,10 +351,9 @@ def main():
# 先通过 ElephantRobot 给机械臂上电并激活
power_on_arm()
server = AGVCommandServer(port=5002)
server = AGVCommandServer(_elephant, port=5002)
# 启动 Flask 视频流服务(端口 5003)
from werkzeug.serving import make_server
arm_server_http = None
for attempt in range(5):
try:
@@ -393,11 +371,16 @@ def main():
def signal_handler(sig, frame):
logger.info("收到停止信号...")
global _ffmpeg_proc
global _ffmpeg_proc, _elephant
if _ffmpeg_proc:
_ffmpeg_proc.terminate()
server.stop()
arm_server_http.shutdown()
if _elephant:
try:
_elephant.stop_client()
except Exception:
pass
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)