Files
opi-yolo/drone_controller/router_proxy.py
T
2026-07-03 13:24:00 +08:00

495 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
router_proxy.py — ZeroMQ 通信代理
【职责】
封装与 Router 的 REQ/SUB 通信,遵循 python_protocol.md 规范:
- REQ (tcp://127.0.0.1:5555) — 发送 SETPOINT / COMMAND / QUERY / WAYPOINT,接收 ACK
- SUB (tcp://127.0.0.1:5556) — 订阅 Router 推送的 STATE / ALERT / PX4_ACK
【使用】
proxy = RouterProxy()
proxy.start()
# 发送指令
ok, ack = proxy.send_command("ARM")
ok, ack = proxy.send_setpoint(vx=1.0, vy=0.0, vz=0.0, control_mode="VELOCITY")
ok, ack = proxy.send_query("HOME_POSITION")
# 读取最新状态(线程安全)
state = proxy.get_latest_state()
if proxy.is_state_fresh(state):
...
proxy.close()
【设计要点】
- REQ_RELAXED + REQ_CORRELATE 防止单次 recv 超时导致 socket 卡死
- SUB 采用专用后台线程 + zmq.Poller 非阻塞接收
- 所有公开接口线程安全
- 状态缓存原子读写,外部获取为深拷贝,无共享引用风险
【作者】根据 python_protocol.md 重构
"""
import zmq
import json
import time
import threading
import copy
import logging
logger = logging.getLogger(__name__)
class RouterProxy:
"""ZeroMQ 通信代理:封装与 Router 的 REQ/SUB 通信"""
# ---- 协议常量 ----
STALE_THRESHOLD_US = 500_000 # 500 ms,STATE 新鲜度阈值(协议 §3.8.1)
STALE_THRESHOLD_WARN_US = 2_000_000 # 2 s,数据稍旧警告阈值
def __init__(self, req_endpoint="tcp://127.0.0.1:5555",
sub_endpoint="tcp://127.0.0.1:5556",
recv_timeout_ms=500):
"""
初始化 ZMQ 代理
Args:
req_endpoint: REQ socket 地址(发给 Router)
sub_endpoint: SUB socket 地址(收 Router 推送)
recv_timeout_ms:REQ recv 超时毫秒数
"""
self.req_endpoint = req_endpoint
self.sub_endpoint = sub_endpoint
self.recv_timeout_ms = recv_timeout_ms
# ---- ZMQ 上下文 ----
self.ctx = zmq.Context()
# ---- REQ socket: 发指令,收 ACK ----
self.req = self.ctx.socket(zmq.REQ)
self.req.setsockopt(zmq.RCVTIMEO, recv_timeout_ms)
# REQ_RELAXED + REQ_CORRELATE: 即使某次 recv 超时,
# 下次 send 仍能正常发送,旧响应会被自动丢弃
self.req.setsockopt(zmq.REQ_RELAXED, 1)
self.req.setsockopt(zmq.REQ_CORRELATE, 1)
self.req.connect(req_endpoint)
# ---- SUB socket: 收 Router 推送 ----
self.sub = self.ctx.socket(zmq.SUB)
self.sub.connect(sub_endpoint)
self.sub.setsockopt_string(zmq.SUBSCRIBE, "") # 订阅所有消息
# ---- 消息序号(线程安全) ----
self._seq = 0
self._seq_lock = threading.Lock()
# ---- 消息ID (区分标识) ----
self.id = 0
self.id_lock = threading.Lock()
# ---- 最新 STATE 缓存 ----
self._latest_state = None
self._state_lock = threading.Lock()
# ---- 首次 STATE 标记 ----
self._first_state_received = False
# ---- SUB 监听线程 ----
self._running = False
self._sub_thread = None
# ---- 外部回调钩子 ----
self._state_callback = None
self._alert_callback = None
self._waypoints_callback = None
self._last_logged_msg_type = None # 记录上次打印的消息类型
self._last_logged_ack_type = None
# ================================================================
# 内部工具
# ================================================================
def _next_id(self) -> int:
"""递增消息ID (区分标识)"""
with self.id_lock:
self.id += 1
return self.id
def _next_seq(self) -> int:
"""递增消息序号(线程安全)"""
with self._seq_lock:
current_seq = self._seq
self._seq += 1
return current_seq
def _build_send_summary(self, msg: dict) -> str:
"""构建发送消息的内容概要,用于日志"""
msg_type = msg.get("type", "?")
if msg_type == "SETPOINT":
sp = msg.get("setpoint", {})
return ", ".join(f"{k}={v}" for k, v in sp.items())
elif msg_type == "COMMAND":
return f"command={msg.get('command')}"
elif msg_type == "QUERY":
return f"query={msg.get('query')}"
elif msg_type == "WAYPOINT":
wp = msg.get("waypoint", {})
return ", ".join(f"{k}={v}" for k, v in wp.items())
return str(msg)
def _recover_req(self):
"""
REQ 超时/异常后重建 socket
虽然设置了 REQ_RELAXED+CORRELATE,但连续超时或 ZMQ 内部状态异常时
重建是最安全的恢复手段。
"""
try:
self.req.close(linger=0)
except Exception:
pass
self.req = self.ctx.socket(zmq.REQ)
self.req.setsockopt(zmq.RCVTIMEO, self.recv_timeout_ms)
self.req.setsockopt(zmq.REQ_RELAXED, 1)
self.req.setsockopt(zmq.REQ_CORRELATE, 1)
self.req.connect(self.req_endpoint)
logger.warning("REQ socket 已重建")
def set_waypoints_callback(self, callback):
"""
注册 QGC_WAYPOINT 回调 (在 SUB 线程调用,不应阻塞)
callback 签名:callback(waypoint_data: dict)
"""
self._waypoints_callback = callback
# ================================================================
# 发送接口(线程安全)
# ================================================================
def send_setpoint(self, control_mode="VELOCITY", **kwargs):
"""
发送 SETPOINT 指令
位置控制:
proxy.send_setpoint(control_mode="POSITION",
x=1.0, y=0.0, z=-0.5, yaw=0.0,
frame="LOCAL_OFFSET_NED")
速度控制:
proxy.send_setpoint(control_mode="VELOCITY",
vx=1.0, vy=0.0, vz=0.0, yaw_rate=0.0)
Args:
control_mode: "POSITION" | "VELOCITY"
**kwargs: 按 control_mode 传入对应字段
Returns:
(成功: bool, ACK 字典: dict)
"""
msg_id = self._next_id()
msg = {
"type": "SETPOINT",
"id": msg_id,
"timestamp_us": int(time.time() * 1_000_000),
"setpoint": {"control_mode": control_mode, **kwargs}
}
return self._send_req(msg)
def send_command(self, command):
"""
发送 COMMAND 指令
Args:
command: "ARM" | "OFFBOARD" | "LAND" | "RTL" | "RESUME"
"""
msg_id = self._next_id()
msg = {
"type": "COMMAND",
"id": msg_id,
"timestamp_us": int(time.time() * 1_000_000),
"command": command
}
return self._send_req(msg)
def send_query(self, query="HOME_POSITION"):
"""
发送 QUERY 查询
Args:
query: "HOME_POSITION"(目前仅支持此查询)
"""
msg_id = self._next_id()
msg = {
"type": "QUERY",
"id": msg_id,
"timestamp_us": int(time.time() * 1_000_000),
"query": query
}
return self._send_req(msg)
def send_waypoint(self, **wp_kwargs):
"""
发送 WAYPOINT 航点
Args:
**wp_kwargs: 航点字段(action, alt, lat, lon, speed, ...)
示例:
proxy.send_waypoint(action="TAKEOFF", alt=15.0, alt_frame="RELATIVE")
proxy.send_waypoint(action="HOVER", lat=22.55, lon=113.88,
alt=100.0, hover_time=3.0)
"""
msg_id = self._next_id()
current_seq = self._next_seq()
msg = {
"type": "WAYPOINT",
"id": msg_id,
"seq": current_seq,
"timestamp_us": int(time.time() * 1_000_000),
"waypoint": wp_kwargs
}
return self._send_req(msg)
# ================================================================
# 底层 REQ-REP 通信
# ================================================================
def _send_req(self, msg):
msg_type = msg.get("type", "?")
msg_id = msg.get("id", "?")
summary = self._build_send_summary(msg)
# ---- 发送日志:类型变化时才打印 ----
if msg_type != self._last_logged_msg_type:
self._last_logged_msg_type = msg_type
logger.debug("Sending %s id=%s: %s", msg_type, msg_id, summary)
logger.info("🚀 发送 %s (类型变化)", msg_type)
# 否则静默,不打印任何东西
try:
self.req.send_string(json.dumps(msg))
ack_str = self.req.recv_string()
ack = json.loads(ack_str)
ok = ack.get("status") == "OK"
ack_type = ack.get("type", "ACK") # 通常是 "ACK"
# ---- ACK 日志:类型变化时才打印 ----
if ack_type != self._last_logged_ack_type:
self._last_logged_ack_type = ack_type
logger.info("ACK received for id=%s, status=%s",
ack.get("ref_id"), ack.get("status"))
# 否则 ACK 也静默
if not ok:
# 非 OK 状态无论如何都打印(这是异常)
logger.warning("ACK 非 OK: type=%s id=%s ack=%s",
msg_type, msg_id, ack)
return ok, ack
except zmq.Again:
logger.error("REQ recv 超时 (type=%s id=%s)", msg_type, msg_id)
self._recover_req()
return False, {"status": "TIMEOUT", "message": "recv timeout"}
except Exception as e:
logger.error("REQ 异常 (type=%s id=%s): %s", msg_type, msg_id, e)
self._recover_req()
return False, {"status": "FAIL", "message": str(e)}
# ================================================================
# 状态获取
# ================================================================
def get_latest_state(self):
"""
获取最新缓存的 STATE(线程安全,深拷贝)
Returns:
state dict(格式见 python_protocol.md §3.8),
未收到过 STATE 时返回 None
"""
with self._state_lock:
return copy.deepcopy(self._latest_state)
def is_state_fresh(self, state, stale_threshold_us=None):
"""
检查 STATE 数据是否新鲜(协议 §3.8.1)
Args:
state: get_latest_state() 的返回值
stale_threshold_us: 新鲜度阈值(默认 500ms)
Returns:
True 数据新鲜,可正常使用
False 数据过期,应保守飞行或触发保护
"""
if state is None:
return False
if stale_threshold_us is None:
stale_threshold_us = self.STALE_THRESHOLD_US
now_us = int(time.time() * 1_000_000)
try:
age_us = now_us - state["drone"]["last_update_us"]
except (KeyError, TypeError):
return False
if age_us >= stale_threshold_us:
if age_us >= self.STALE_THRESHOLD_WARN_US:
logger.warning("STATE 严重过期: age=%.2fs(阈值=%.0fms)",
age_us / 1e6, stale_threshold_us / 1000)
else:
logger.warning("STATE 过期: age=%.1fms(阈值=%.0fms)",
age_us / 1000, stale_threshold_us / 1000)
return age_us < stale_threshold_us
def get_state_freshness(self, state):
"""
返回 STATE 新鲜度详细信息(用于日志/调试)
Returns:
(age_us: int, is_fresh: bool) 或 (None, False)
"""
if state is None:
return None, False
try:
now_us = int(time.time() * 1_000_000)
age_us = now_us - state["drone"]["last_update_us"]
return age_us, age_us < self.STALE_THRESHOLD_US
except (KeyError, TypeError):
return None, False
# ---- 回调注册 ----
def set_state_callback(self, callback):
"""
注册 STATE 回调(在 SUB 线程中调用,不应阻塞)
callback 签名: callback(state_dict)
"""
self._state_callback = callback
def set_alert_callback(self, callback):
"""
注册 ALERT 回调(在 SUB 线程中调用,不应阻塞)
callback 签名: callback(alert_dict)
"""
self._alert_callback = callback
# ================================================================
# 生命周期管理
# ================================================================
def start(self):
"""启动 SUB 监听后台线程"""
if self._running:
return
self._running = True
self._sub_thread = threading.Thread(
target=self._sub_worker,
name="router-proxy-sub",
daemon=True
)
self._sub_thread.start()
logger.info("RouterProxy 已启动,SUB 监听线程已开启(REQ=%s SUB=%s)",
self.req_endpoint, self.sub_endpoint)
def _sub_worker(self):
"""后台线程:通过 zmq.Poller 非阻塞接收 SUB 消息"""
poller = zmq.Poller()
poller.register(self.sub, zmq.POLLIN)
while self._running:
try:
socks = dict(poller.poll(timeout=100)) # 100 ms 超时轮询
if self.sub not in socks:
continue
msg_str = self.sub.recv_string()
data = json.loads(msg_str)
msg_type = data.get("type")
# logger.info(f"📨 SUB 收到消息类型: {msg_type}")
if msg_type == "STATE":
with self._state_lock:
self._latest_state = data
if not self._first_state_received:
self._first_state_received = True
logger.info("首次收到 Router 状态推送")
# DEBUG 输出关键字段 + 新鲜度
try:
drone = data.get("drone", {})
now_us = int(time.time() * 1_000_000)
last_up = drone.get("last_update_us", now_us)
age_us = now_us - last_up
logger.debug("STATE: mode=%s armed=%s "
"alt_rel=%.1f batt=%.0f%% age=%.0fms",
drone.get("mode"), drone.get("armed"),
drone.get("alt_rel", 0),
drone.get("battery", 0),
age_us / 1000)
except Exception:
pass
if self._state_callback:
try:
self._state_callback(data)
except Exception as e:
logger.error("STATE 回调异常: %s", e)
elif msg_type == "ALERT":
logger.warning("⚠️ ALERT: %s — %s",
data.get("alert"), data.get("message"))
if self._alert_callback:
try:
self._alert_callback(data)
except Exception as e:
logger.error("ALERT 回调异常: %s", e)
elif msg_type == "PX4_ACK":
logger.info("PX4_ACK: cmd=%s result=%s",
data.get("ref_cmd"), data.get("result"))
elif msg_type == "QGC_WAYPOINTS":
logger.info("📥 收到 QGC_WAYPOINTS: count=%d", data.get("count", 0))
if self._waypoints_callback:
try:
self._waypoints_callback(data)
except Exception as e:
logger.error("QGC_WAYPOINTS 回调异常: %s", e)
elif msg_type == "QUERY_REPLY":
# QUERY_REPLY 由 send_query 的同步 recv 处理,
# SUB 通道上的 QUERY_REPLY 暂不处理
pass
else:
logger.warning("收到未知消息类型: %s", msg_type)
except zmq.Again:
continue
except json.JSONDecodeError as e:
logger.warning("SUB 收到非 JSON 消息: %s", e)
continue
except Exception as e:
logger.error("SUB 线程异常: %s", e)
time.sleep(0.1)
def close(self):
"""关闭代理,释放所有 ZMQ 资源"""
self._running = False
if self._sub_thread and self._sub_thread.is_alive():
self._sub_thread.join(timeout=2.0)
for name, sock in [("REQ", self.req), ("SUB", self.sub)]:
try:
sock.close(linger=0)
except Exception:
pass
try:
self.ctx.term()
except Exception:
pass
logger.info("RouterProxy 已关闭")