返回教程正文

配套源码

websocket_client.py

ros2/ventuno_app_bridge/websocket_client.py
ros_gateway Brick:在 App Lab 中建立可靠的 WebSocket 通道ros2/ventuno_app_bridge/websocket_client.py
Python313 行
  1. # SPDX-License-Identifier: MIT
  2. import json
  3. import queue
  4. import threading
  5. import time
  6. from websockets.exceptions import ConnectionClosed
  7. from websockets.sync.client import connect
  8. PROTOCOL_VERSION = 1
  9. class WebSocketBridgeClient:
  10. """在后台线程中维护 App Lab WebSocket 连接。"""
  11. def __init__(
  12. self,
  13. websocket_url,
  14. reconnect_interval,
  15. heartbeat_interval,
  16. command_timeout,
  17. message_callback,
  18. connection_callback,
  19. log_callback,
  20. initial_mode="ROS_TELEOP",
  21. ):
  22. """
  23. @description : 创建带重连、心跳和有界队列的 WebSocket 客户端
  24. @param websocket_url : App Lab WebSocket 地址
  25. @param reconnect_interval : 断线后的重连间隔秒数
  26. @param heartbeat_interval : 应用层心跳间隔秒数
  27. @param command_timeout : 本地速度命令过期时间秒数
  28. @param message_callback : 接收服务端消息的回调
  29. @param connection_callback : 连接状态变化回调
  30. @param log_callback : 线程安全日志回调
  31. @param initial_mode : 每次握手后请求的底盘模式
  32. @return : 无返回值
  33. """
  34. self._url = websocket_url
  35. self._reconnect_interval = max(0.1, float(reconnect_interval))
  36. self._heartbeat_interval = max(0.1, float(heartbeat_interval))
  37. self._command_timeout = max(0.05, float(command_timeout))
  38. self._message_callback = message_callback
  39. self._connection_callback = connection_callback
  40. self._log_callback = log_callback
  41. self._initial_mode = initial_mode
  42. self._stop_event = threading.Event()
  43. self._thread = None
  44. self._state_lock = threading.Lock()
  45. self._connected = False
  46. self._sequence = 0
  47. self._latest_command = queue.Queue(maxsize=1)
  48. self._control_queue = queue.Queue(maxsize=8)
  49. def start(self):
  50. """
  51. @description : 启动自动重连后台线程
  52. @param : 无参数
  53. @return : 无返回值
  54. """
  55. if self._thread and self._thread.is_alive():
  56. return
  57. self._stop_event.clear()
  58. self._thread = threading.Thread(
  59. target=self._run,
  60. name="ventuno-websocket-client",
  61. daemon=True,
  62. )
  63. self._thread.start()
  64. def stop(self):
  65. """
  66. @description : 请求客户端线程停止并等待退出
  67. @param : 无参数
  68. @return : 无返回值
  69. """
  70. self._stop_event.set()
  71. if self._thread and self._thread is not threading.current_thread():
  72. self._thread.join(timeout=5.0)
  73. self._set_connected(False)
  74. def is_connected(self):
  75. """
  76. @description : 查询当前 WebSocket 握手状态
  77. @param : 无参数
  78. @return : 已连接返回 True,否则返回 False
  79. """
  80. with self._state_lock:
  81. return self._connected
  82. def send_cmd_vel(self, vx, vy, wz):
  83. """
  84. @description : 用最新速度覆盖尚未发送的旧速度,避免控制队列增长
  85. @param vx : 纵向速度,单位 m/s
  86. @param vy : 横向速度,单位 m/s
  87. @param wz : 偏航角速度,单位 rad/s
  88. @return : 已进入本地队列返回 True
  89. """
  90. command = {
  91. "timestamp_ms": self._now_ms(),
  92. "vx": float(vx),
  93. "vy": float(vy),
  94. "wz": float(wz),
  95. }
  96. self._replace_queue_item(self._latest_command, command)
  97. return True
  98. def request_mode(self, mode):
  99. """
  100. @description : 将需要应答的模式切换请求加入有界控制队列
  101. @param mode : 目标模式字符串
  102. @return : 成功入队返回 True,队列满返回 False
  103. """
  104. request = {
  105. "type": "mode_change",
  106. "timestamp_ms": self._now_ms(),
  107. "mode": mode,
  108. }
  109. try:
  110. self._control_queue.put_nowait(request)
  111. return True
  112. except queue.Full:
  113. self._log("warning", "mode request queue is full")
  114. return False
  115. def _run(self):
  116. """
  117. @description : 反复连接服务端并在断线后按配置等待重连
  118. @param : 无参数
  119. @return : 无返回值
  120. """
  121. while not self._stop_event.is_set():
  122. try:
  123. self._run_connection()
  124. except Exception as exc:
  125. self._log("warning", f"WebSocket disconnected: {type(exc).__name__}: {exc}")
  126. finally:
  127. self._set_connected(False)
  128. if not self._stop_event.is_set():
  129. self._stop_event.wait(self._reconnect_interval)
  130. def _run_connection(self):
  131. """
  132. @description : 完成单次握手并执行同线程收发循环
  133. @param : 无参数
  134. @return : 无返回值
  135. """
  136. self._log("info", f"connecting to {self._url}")
  137. with connect(
  138. self._url,
  139. open_timeout=3.0,
  140. close_timeout=2.0,
  141. ping_interval=1.0,
  142. ping_timeout=1.0,
  143. max_size=16 * 1024,
  144. max_queue=16,
  145. ) as websocket:
  146. self._send_json(
  147. websocket,
  148. {
  149. "version": PROTOCOL_VERSION,
  150. "type": "hello",
  151. "role": "ros2",
  152. "node": "ventuno_app_bridge_node",
  153. },
  154. )
  155. hello = self._receive_json(websocket, timeout=3.0)
  156. if hello.get("version") != PROTOCOL_VERSION:
  157. raise RuntimeError("server protocol version mismatch")
  158. if hello.get("type") != "hello" or hello.get("role") != "app":
  159. raise RuntimeError("invalid server hello")
  160. self._sequence = 0
  161. self._set_connected(True)
  162. self.request_mode(self._initial_mode)
  163. next_heartbeat = time.monotonic()
  164. while not self._stop_event.is_set():
  165. current = time.monotonic()
  166. if current >= next_heartbeat:
  167. self._send_json(websocket, self._next_message("heartbeat"))
  168. next_heartbeat = current + self._heartbeat_interval
  169. self._send_control_messages(websocket)
  170. self._send_latest_command(websocket)
  171. try:
  172. message = self._receive_json(websocket, timeout=0.05)
  173. except TimeoutError:
  174. continue
  175. self._message_callback(message)
  176. def _send_control_messages(self, websocket):
  177. """
  178. @description : 发送当前有界控制队列中的模式请求
  179. @param websocket : 当前 WebSocket 连接
  180. @return : 无返回值
  181. """
  182. while True:
  183. try:
  184. request = self._control_queue.get_nowait()
  185. except queue.Empty:
  186. return
  187. message_type = request.pop("type")
  188. self._send_json(websocket, self._next_message(message_type, **request))
  189. def _send_latest_command(self, websocket):
  190. """
  191. @description : 发送最新且未过期的速度命令
  192. @param websocket : 当前 WebSocket 连接
  193. @return : 无返回值
  194. """
  195. try:
  196. command = self._latest_command.get_nowait()
  197. except queue.Empty:
  198. return
  199. age_seconds = (self._now_ms() - command["timestamp_ms"]) / 1000.0
  200. if age_seconds > self._command_timeout:
  201. self._log("warning", f"dropping stale local cmd_vel: age={age_seconds:.3f}s")
  202. return
  203. self._send_json(websocket, self._next_message("cmd_vel", **command))
  204. def _next_message(self, message_type, **fields):
  205. """
  206. @description : 构造具有统一版本和严格递增序号的客户端消息
  207. @param message_type : 消息类型
  208. @param fields : 附加字段
  209. @return : 待发送消息字典
  210. """
  211. self._sequence += 1
  212. message = {
  213. "version": PROTOCOL_VERSION,
  214. "type": message_type,
  215. "seq": self._sequence,
  216. "timestamp_ms": fields.pop("timestamp_ms", self._now_ms()),
  217. }
  218. message.update(fields)
  219. return message
  220. def _set_connected(self, connected):
  221. """
  222. @description : 原子更新连接状态并仅在变化时通知 ROS 2 节点
  223. @param connected : 新连接状态
  224. @return : 无返回值
  225. """
  226. changed = False
  227. with self._state_lock:
  228. if self._connected != connected:
  229. self._connected = connected
  230. changed = True
  231. if changed:
  232. self._connection_callback(connected)
  233. def _log(self, level, message):
  234. """
  235. @description : 将后台线程日志转交给 ROS 2 节点
  236. @param level : info、warning 或 error
  237. @param message : 日志文本
  238. @return : 无返回值
  239. """
  240. self._log_callback(level, message)
  241. @staticmethod
  242. def _replace_queue_item(target_queue, item):
  243. """
  244. @description : 用新元素替换单元素队列中的旧元素
  245. @param target_queue : 目标有界队列
  246. @param item : 新元素
  247. @return : 无返回值
  248. """
  249. try:
  250. target_queue.get_nowait()
  251. except queue.Empty:
  252. pass
  253. target_queue.put_nowait(item)
  254. @staticmethod
  255. def _send_json(websocket, message):
  256. """
  257. @description : 将消息编码为紧凑 JSON 文本并发送
  258. @param websocket : 当前 WebSocket 连接
  259. @param message : 待发送消息字典
  260. @return : 无返回值
  261. """
  262. websocket.send(json.dumps(message, separators=(",", ":")))
  263. @staticmethod
  264. def _receive_json(websocket, timeout):
  265. """
  266. @description : 接收、解析并基础校验服务端 JSON 消息
  267. @param websocket : 当前 WebSocket 连接
  268. @param timeout : 接收超时秒数
  269. @return : 已解析消息字典
  270. """
  271. raw_message = websocket.recv(timeout=timeout)
  272. message = json.loads(raw_message)
  273. if not isinstance(message, dict):
  274. raise RuntimeError("server JSON root must be an object")
  275. return message
  276. @staticmethod
  277. def _now_ms():
  278. """
  279. @description : 获取 Unix 毫秒时间戳
  280. @param : 无参数
  281. @return : 当前 Unix 毫秒时间戳
  282. """
  283. return time.time_ns() // 1_000_000