""" 机械臂服务端 - 机械臂端主程序 运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流) 通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot) 同时通过 ffmpeg 提供 HTTP 视频流 """ import socket import threading import time 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 # 添加当前目录到路径 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # 配置日志 logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler(os.path.expanduser("~/work/arm_server/server.log")) ] ) logger = logging.getLogger("arm_server") # ========== Flask HTTP 服务器 - 视频流 (ffmpeg) ========== arm_video_app = Flask(__name__) ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号 _ffmpeg_proc = None _ffmpeg_thread = None _ffmpeg_lock = threading.Lock() _frame_cond = threading.Condition() _latest_frame = None _latest_frame_ts = 0.0 _stop_ffmpeg_reader = threading.Event() _invalid_count = 0 # 连续无效帧计数 _MAX_INVALID = 30 # 连续 30 帧无效 → 重启 ffmpeg _MAX_BUF_SIZE = 2 * 1024 * 1024 # 2MB buffer 上限 def _validate_jpeg(data): """验证 JPEG 数据是否有效,返回 True/False""" try: Image.open(io.BytesIO(data)).verify() return True except Exception: return False def _stop_ffmpeg(): """停止 ffmpeg 采集进程和读帧线程。""" global _ffmpeg_proc _stop_ffmpeg_reader.set() if _ffmpeg_proc and _ffmpeg_proc.poll() is None: _ffmpeg_proc.terminate() try: _ffmpeg_proc.wait(timeout=2) except subprocess.TimeoutExpired: _ffmpeg_proc.kill() _ffmpeg_proc = None def _frame_reader(): """从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,校验有效性并缓存最新一帧。 当摄像头 USB 掉线重连时,ffmpeg 会从失效 fd 读取垃圾数据, 产生假 JPEG 帧(花屏)。这里通过 PIL 校验帧有效性, 连续无效帧过多时自动重启 ffmpeg 恢复。 """ global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count buf = b"" while not _stop_ffmpeg_reader.is_set(): proc = _ffmpeg_proc if proc is None or proc.poll() is not None or proc.stdout is None: time.sleep(0.1) continue chunk = proc.stdout.read(8192) if not chunk: if proc.poll() is not None: break 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 if start < 0: buf = buf[-2:] break if end < 0: buf = buf[start:] break frame = buf[start:end + 2] buf = buf[end + 2:] # JPEG 校验:摄像头掉线时帧数据会损坏 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 # 跳出循环让 _ensure_ffmpeg 重建 def _ensure_ffmpeg(): """确保 ffmpeg 进程在运行,自动重启崩溃的进程""" global _ffmpeg_proc, _ffmpeg_thread, _invalid_count with _ffmpeg_lock: if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return _stop_ffmpeg_reader.set() 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( [ "ffmpeg", "-f", "v4l2", "-input_format", "mjpeg", "-framerate", "8", "-video_size", "640x480", "-i", f"/dev/video{ARM_CAMERA_INDEX}", "-fflags", "nobuffer", "-analyzeduration", "0", "-f", "mjpeg", "-" ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) _ffmpeg_thread = threading.Thread(target=_frame_reader, daemon=True) _ffmpeg_thread.start() def _get_latest_frame(timeout: float = 3.0): """返回缓存的最新 JPEG 帧;必要时等待首帧。""" _ensure_ffmpeg() deadline = time.time() + timeout with _frame_cond: while _latest_frame is None and time.time() < deadline: _frame_cond.wait(timeout=0.2) return _latest_frame @arm_video_app.route("/api/camera/preview") def arm_camera_preview(): """机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。""" _ensure_ffmpeg() def generate(): last_ts = 0.0 try: while True: frame = _get_latest_frame(timeout=3.0) if frame is None: logger.warning("等待摄像头帧超时,重启 ffmpeg") _stop_ffmpeg() continue with _frame_cond: if _latest_frame_ts <= last_ts: _frame_cond.wait(timeout=1.0) frame = _latest_frame last_ts = _latest_frame_ts if frame: yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") except Exception as e: logger.error(f"视频流异常: {e}") finally: logger.info("视频流连接关闭") return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") @arm_video_app.route("/api/camera/status") 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, "invalid_count": _invalid_count }) @arm_video_app.route("/api/camera/restart", methods=["POST"]) def arm_camera_restart(): """重启视频流""" 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}) @arm_video_app.route("/api/camera/snapshot") def arm_camera_snapshot(): """机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。""" frame = _get_latest_frame(timeout=3.0) if frame: r = Response(frame, mimetype="image/jpeg") r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" r.headers["Pragma"] = "no-cache" r.headers["Expires"] = "0" return r logger.warning("snapshot failed: no cached frame") return "", 500 # ========== TCP 服务器 - 接收 AGV 指令 ========== class AGVCommandServer: """TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow""" 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 # 直接从外部注入已激活的 ElephantRobot 实例 if elephant is None: logger.warning("ElephantRobot 实例为空,命令将返回错误") self._el = elephant def start(self): self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._sock.bind((self.host, self.port)) self._sock.listen(5) self._running = True logger.info(f"=" * 50) logger.info(f"机械臂服务端已启动,监听 {self.host}:{self.port}") logger.info(f"等待 AGV 连接...") logger.info(f"=" * 50) while self._running: try: self._sock.settimeout(1.0) try: client_sock, addr = self._sock.accept() logger.info(f"AGV 已连接: {addr}") threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True).start() except socket.timeout: continue except Exception as e: if self._running: logger.error(f"服务器异常: {e}") break def _handle_client(self, client_sock: socket.socket): try: client_sock.settimeout(30) buffer = "" while self._running: try: data = client_sock.recv(4096) if not data: break buffer += data.decode("utf-8") while "\n" in buffer: line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue response = self._execute_command(line) client_sock.sendall((response + "\n").encode("utf-8")) logger.info(f"CMD: {line} → {response}") except socket.timeout: continue except Exception as e: logger.error(f"客户端处理异常: {e}") finally: client_sock.close() logger.info("AGV 客户端已断开") def _execute_command(self, cmd: str) -> str: """通过 ElephantRobot.send_command 转发给 RoboFlow""" if self._el is None: return "ERROR: Robot not initialized" try: return self._el.send_command(cmd) except Exception as e: return f"ERROR: {e}" def stop(self): self._running = False if self._sock: try: self._sock.close() except: pass logger.info("机械臂服务端已停止") # ========== 入口 ========== _elephant = None # 全局 ElephantRobot 实例 def power_on_arm(max_retries: int = 5) -> bool: """通过 ElephantRobot 给机械臂上电并激活(带重试),返回 ElephantRobot 实例""" global _elephant from pymycobot import ElephantRobot for attempt in range(1, max_retries + 1): try: logger.info(f"正在通过 ElephantRobot 连接 RoboFlow (尝试 {attempt}/{max_retries})...") el = ElephantRobot("127.0.0.1", 5001) el.start_client() logger.info("ElephantRobot start_client 成功,等待2秒...") time.sleep(2) el._power_on() logger.info("power_on 指令已发送,等待2秒...") time.sleep(2) el.start_robot() logger.info("start_robot 指令已发送,等待5秒...") time.sleep(5) logger.info("✅ 机械臂上电+激活 全部完成") # 保存到全局,确保后续复用 _elephant = el return True except Exception as e: logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}") if attempt < max_retries: logger.info(f"等待 3 秒后重试...") time.sleep(3) else: logger.error(f"❌ 所有 {max_retries} 次尝试均失败,将以 limited 模式运行") return False return False def main(): import signal # 先通过 ElephantRobot 给机械臂上电并激活 power_on_arm() # 将全局 _elephant 传给指令服务器 server = AGVCommandServer(_elephant, port=5002) # 启动 Flask 视频流服务(端口 5003) arm_server_http = None for attempt in range(5): try: arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True) break except OSError as e: if attempt < 4 and "Address already in use" in str(e): logger.warning(f"端口 5003 被占用(第{attempt+1}次),等待...") time.sleep(3) else: raise http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True) http_thread.start() logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003") def signal_handler(sig, frame): logger.info("收到停止信号...") global _ffmpeg_proc, _elephant if _ffmpeg_proc: _ffmpeg_proc.terminate() server.stop() arm_server_http.shutdown() if _elephant: try: _elephant.stop_client() except: pass sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) server.start() if __name__ == "__main__": main()