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:
+66
-83
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user