返回教程正文

配套源码

node.py

ros2/ventuno_app_bridge/node.py
ros_gateway Brick:在 App Lab 中建立可靠的 WebSocket 通道ros2/ventuno_app_bridge/node.py
Python241 行
  1. # SPDX-License-Identifier: MIT
  2. import json
  3. import queue
  4. import rclpy
  5. from geometry_msgs.msg import Twist, TwistStamped
  6. from rclpy.node import Node
  7. from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy
  8. from std_msgs.msg import Bool, String
  9. from .websocket_client import WebSocketBridgeClient
  10. class VentunoAppBridgeNode(Node):
  11. """把标准 ROS 2 速度话题转换为 App Lab WebSocket 协议。"""
  12. def __init__(self):
  13. """
  14. @description : 声明参数、创建 ROS 接口并启动 WebSocket 客户端
  15. @param : 无参数
  16. @return : 无返回值
  17. """
  18. super().__init__("ventuno_app_bridge_node")
  19. self.declare_parameter("websocket_url", "ws://127.0.0.1:8765/ros")
  20. self.declare_parameter("reconnect_interval", 2.0)
  21. self.declare_parameter("heartbeat_interval", 1.0)
  22. self.declare_parameter("command_timeout", 0.3)
  23. self.declare_parameter("use_twist_stamped", False)
  24. self._incoming_messages = queue.Queue(maxsize=64)
  25. self._connection_events = queue.Queue(maxsize=4)
  26. self._last_connection_state = None
  27. connection_qos = QoSProfile(
  28. depth=1,
  29. reliability=ReliabilityPolicy.RELIABLE,
  30. durability=DurabilityPolicy.TRANSIENT_LOCAL,
  31. )
  32. self._connection_publisher = self.create_publisher(
  33. Bool,
  34. "/ventuno/connection",
  35. connection_qos,
  36. )
  37. self._base_state_publisher = self.create_publisher(
  38. String,
  39. "/ventuno/base_state",
  40. 10,
  41. )
  42. use_twist_stamped = self.get_parameter("use_twist_stamped").value
  43. if use_twist_stamped:
  44. self._cmd_vel_subscription = self.create_subscription(
  45. TwistStamped,
  46. "/cmd_vel",
  47. self._handle_twist_stamped,
  48. 10,
  49. )
  50. command_type = "geometry_msgs/msg/TwistStamped"
  51. else:
  52. self._cmd_vel_subscription = self.create_subscription(
  53. Twist,
  54. "/cmd_vel",
  55. self._handle_twist,
  56. 10,
  57. )
  58. command_type = "geometry_msgs/msg/Twist"
  59. self._client = WebSocketBridgeClient(
  60. websocket_url=self.get_parameter("websocket_url").value,
  61. reconnect_interval=self.get_parameter("reconnect_interval").value,
  62. heartbeat_interval=self.get_parameter("heartbeat_interval").value,
  63. command_timeout=self.get_parameter("command_timeout").value,
  64. message_callback=self._queue_incoming_message,
  65. connection_callback=self._queue_connection_event,
  66. log_callback=self._log_from_thread,
  67. )
  68. self._drain_timer = self.create_timer(0.05, self._drain_events)
  69. self._client.start()
  70. self.get_logger().info(
  71. f"subscribing /cmd_vel as {command_type}; "
  72. f"gateway={self.get_parameter('websocket_url').value}"
  73. )
  74. def destroy_node(self):
  75. """
  76. @description : 在销毁 ROS 2 节点前停止 WebSocket 后台线程
  77. @param : 无参数
  78. @return : 父类销毁结果
  79. """
  80. self._client.stop()
  81. return super().destroy_node()
  82. def _handle_twist(self, message):
  83. """
  84. @description : 将 Twist 速度消息提交到只保留最新值的发送队列
  85. @param message : geometry_msgs/msg/Twist 消息
  86. @return : 无返回值
  87. """
  88. self._client.send_cmd_vel(
  89. message.linear.x,
  90. message.linear.y,
  91. message.angular.z,
  92. )
  93. def _handle_twist_stamped(self, message):
  94. """
  95. @description : 将 TwistStamped 内的速度提交到发送队列
  96. @param message : geometry_msgs/msg/TwistStamped 消息
  97. @return : 无返回值
  98. """
  99. self._handle_twist(message.twist)
  100. def _queue_incoming_message(self, message):
  101. """
  102. @description : 从 WebSocket 线程向 ROS 主线程投递消息
  103. @param message : 已解析的服务端消息字典
  104. @return : 无返回值
  105. """
  106. self._replace_bounded(self._incoming_messages, message)
  107. def _queue_connection_event(self, connected):
  108. """
  109. @description : 从 WebSocket 线程向 ROS 主线程投递连接状态
  110. @param connected : 新连接状态
  111. @return : 无返回值
  112. """
  113. self._replace_bounded(self._connection_events, bool(connected))
  114. def _log_from_thread(self, level, message):
  115. """
  116. @description : 输出 WebSocket 后台线程产生的诊断日志
  117. @param level : 日志级别
  118. @param message : 日志文本
  119. @return : 无返回值
  120. """
  121. logger = self.get_logger()
  122. if level == "error":
  123. logger.error(message)
  124. elif level == "warning":
  125. logger.warning(message)
  126. else:
  127. logger.info(message)
  128. def _drain_events(self):
  129. """
  130. @description : 在 ROS 主线程中发布连接状态和 App 模拟状态
  131. @param : 无参数
  132. @return : 无返回值
  133. """
  134. while True:
  135. try:
  136. connected = self._connection_events.get_nowait()
  137. except queue.Empty:
  138. break
  139. self._publish_connection(connected)
  140. while True:
  141. try:
  142. message = self._incoming_messages.get_nowait()
  143. except queue.Empty:
  144. break
  145. self._handle_gateway_message(message)
  146. def _publish_connection(self, connected):
  147. """
  148. @description : 发布连接状态并在状态变化时写日志
  149. @param connected : 当前连接状态
  150. @return : 无返回值
  151. """
  152. status_message = Bool()
  153. status_message.data = connected
  154. self._connection_publisher.publish(status_message)
  155. if self._last_connection_state != connected:
  156. state_text = "connected" if connected else "disconnected"
  157. self.get_logger().info(f"App Lab gateway {state_text}")
  158. self._last_connection_state = connected
  159. def _handle_gateway_message(self, message):
  160. """
  161. @description : 将服务端消息转换为当前阶段 ROS 2 输出
  162. @param message : 已解析服务端消息
  163. @return : 无返回值
  164. """
  165. message_type = message.get("type")
  166. if message_type == "base_state":
  167. raw_state = String()
  168. raw_state.data = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
  169. self._base_state_publisher.publish(raw_state)
  170. elif message_type == "ack":
  171. self.get_logger().info(
  172. f"gateway ack: command={message.get('command')} "
  173. f"accepted={message.get('accepted')} mode={message.get('mode')}"
  174. )
  175. elif message_type == "error":
  176. self.get_logger().warning(
  177. f"gateway rejected message: code={message.get('code')} "
  178. f"detail={message.get('message')}"
  179. )
  180. @staticmethod
  181. def _replace_bounded(target_queue, value):
  182. """
  183. @description : 有界队列满时丢弃最旧事件并插入最新事件
  184. @param target_queue : 目标队列
  185. @param value : 新事件
  186. @return : 无返回值
  187. """
  188. try:
  189. target_queue.put_nowait(value)
  190. return
  191. except queue.Full:
  192. pass
  193. try:
  194. target_queue.get_nowait()
  195. except queue.Empty:
  196. pass
  197. target_queue.put_nowait(value)
  198. def main(args=None):
  199. """
  200. @description : 初始化 rclpy 并运行 Ventuno App Bridge 节点
  201. @param args : 可选 ROS 2 命令行参数
  202. @return : 无返回值
  203. """
  204. rclpy.init(args=args)
  205. node = VentunoAppBridgeNode()
  206. try:
  207. rclpy.spin(node)
  208. except KeyboardInterrupt:
  209. pass
  210. finally:
  211. node.destroy_node()
  212. if rclpy.ok():
  213. rclpy.shutdown()
  214. if __name__ == "__main__":
  215. main()