|
|
@@ -3,6 +3,8 @@ import re
|
|
|
import rclpy
|
|
|
import subprocess
|
|
|
import json
|
|
|
+import signal
|
|
|
+import psutil
|
|
|
from rclpy.action import ActionServer
|
|
|
from rclpy.node import Node
|
|
|
from geometry_msgs.msg import Twist
|
|
|
@@ -46,8 +48,21 @@ class CustomActionServer(Node):
|
|
|
# self.arm_grasp_init()
|
|
|
# 配置标志:等待 /ai/config 到达后再初始化声音和语言
|
|
|
self.config_ready = False
|
|
|
+ self.config_init_done = False # 确保初始化只执行一次
|
|
|
+ self._config_debounce_timer = None # 防抖定时器
|
|
|
+ self._config_debounce_delay = 0.5 # 防抖延迟(秒)
|
|
|
self.get_logger().info("action service started, waiting for /ai/config...")
|
|
|
|
|
|
+ # 注册 shutdown 回调,确保退出时清理子进程
|
|
|
+ self.get_logger().info("[Shutdown] Registered shutdown handler")
|
|
|
+
|
|
|
+ def destroy_node(self):
|
|
|
+ """
|
|
|
+ 重写 destroy_node,确保节点销毁前清理所有子进程
|
|
|
+ """
|
|
|
+ self._on_shutdown()
|
|
|
+ super().destroy_node()
|
|
|
+
|
|
|
def init_param_config(self):
|
|
|
"""
|
|
|
初始化参数配置 / Initialize parameter configuration
|
|
|
@@ -210,32 +225,38 @@ class CustomActionServer(Node):
|
|
|
)
|
|
|
self.get_logger().debug(f'[配置] image_topic 已更新并重建订阅: {self.image_topic}')
|
|
|
|
|
|
- # useolinetts(需要重新初始化 TTS)
|
|
|
+ # useolinetts(配置变化不触发初始化,由外部重启节点处理)
|
|
|
new_useolinetts = action_service_cfg.get('useolinetts', self.useolinetts)
|
|
|
if new_useolinetts != self.useolinetts:
|
|
|
self.useolinetts = new_useolinetts
|
|
|
- self.get_logger().debug(f'[配置] useolinetts 已更新: {self.useolinetts},重新初始化 TTS')
|
|
|
- # 重新初始化 TTS 模型
|
|
|
- self._init_tts_model()
|
|
|
+ self.get_logger().warn(f'[配置] useolinetts 已更新: {self.useolinetts},需要重启节点以生效')
|
|
|
|
|
|
- # language(可能需要重新初始化)
|
|
|
+ # language(配置变化不触发初始化)
|
|
|
new_language = action_service_cfg.get('language', self.language)
|
|
|
if new_language != self.language:
|
|
|
self.language = new_language
|
|
|
- self.get_logger().debug(f'[配置] language 已更新: {self.language}')
|
|
|
+ self.get_logger().warn(f'[配置] language 已更新: {self.language},需要重启节点以生效')
|
|
|
|
|
|
- # regional_setting(可能需要重新初始化)
|
|
|
+ # regional_setting(配置变化不触发初始化)
|
|
|
new_regional_setting = action_service_cfg.get('regional_setting', self.regional_setting)
|
|
|
if new_regional_setting != self.regional_setting:
|
|
|
self.regional_setting = new_regional_setting
|
|
|
- self.get_logger().info(f'[配置] regional_setting 已更新: {self.regional_setting}')
|
|
|
+ self.get_logger().warn(f'[配置] regional_setting 已更新: {self.regional_setting},需要重启节点以生效')
|
|
|
+
|
|
|
+ # --- large_model 配置处理 ---
|
|
|
+ large_model_cfg = config_root.get('large_model', {})
|
|
|
+ if large_model_cfg:
|
|
|
+ # 调用 model_client 的 update_config 方法更新配置
|
|
|
+ self.model_client.update_config(large_model_cfg)
|
|
|
+ self.get_logger().debug(
|
|
|
+ f'[配置] large_model 已更新: oline_tts_model={large_model_cfg.get("oline_tts_model")}, '
|
|
|
+ f'voice_tone={large_model_cfg.get("voice_tone")}, tts_supplier={large_model_cfg.get("tts_supplier")}'
|
|
|
+ )
|
|
|
|
|
|
- # --- 首次配置到达:完成延迟初始化的模块 ---
|
|
|
+ # --- 首次配置到达:防抖执行一次性初始化 ---
|
|
|
if not self.config_ready:
|
|
|
- self.system_sound_init()
|
|
|
- self.init_language()
|
|
|
self.config_ready = True
|
|
|
- self.get_logger().debug('[配置] 首次配置已到达,声音和语言模块初始化完成')
|
|
|
+ self._schedule_config_init() # 防抖延迟执行初始化
|
|
|
|
|
|
# --- topics 配置处理 ---
|
|
|
topics = config_root.get('topics', {})
|
|
|
@@ -254,18 +275,44 @@ class CustomActionServer(Node):
|
|
|
except Exception as e:
|
|
|
self.get_logger().warn(f'解析配置数据失败: {e}')
|
|
|
|
|
|
+ def _on_config_ready(self):
|
|
|
+ """
|
|
|
+ 防抖处理:配置稳定后执行一次性初始化
|
|
|
+ """
|
|
|
+ if self.config_init_done:
|
|
|
+ return # 已经初始化过,跳过
|
|
|
+
|
|
|
+ self.config_init_done = True
|
|
|
+ self.get_logger().info('[配置] 开始执行一次性初始化...')
|
|
|
+ self.system_sound_init()
|
|
|
+ self.init_language()
|
|
|
+ self.get_logger().info('[配置] 一次性初始化完成')
|
|
|
+
|
|
|
+ def _schedule_config_init(self):
|
|
|
+ """
|
|
|
+ 防抖:延迟执行初始化,确保短时间内多次配置只执行一次
|
|
|
+ """
|
|
|
+ if self._config_debounce_timer:
|
|
|
+ self._config_debounce_timer.cancel()
|
|
|
+
|
|
|
+ self._config_debounce_timer = threading.Timer(
|
|
|
+ self._config_debounce_delay,
|
|
|
+ self._on_config_ready
|
|
|
+ )
|
|
|
+ self._config_debounce_timer.start()
|
|
|
+
|
|
|
def _init_tts_model(self):
|
|
|
"""
|
|
|
- 根据当前 useolinetts / regional_setting 重新初始化 TTS 模型
|
|
|
- 供首次配置到达和 config 动态更新时调用
|
|
|
+ 根据当前 useolinetts / regional_setting 初始化 TTS 模型
|
|
|
"""
|
|
|
pkg_path = get_package_share_directory("largemodel")
|
|
|
|
|
|
if self.regional_setting == "China":
|
|
|
if self.useolinetts:
|
|
|
model_type = "oline"
|
|
|
+ # Qwen-TTS 只返回 WAV 格式,不支持 MP3
|
|
|
self.tts_out_path = os.path.join(
|
|
|
- pkg_path, "resources_file", "tts_output.mp3"
|
|
|
+ pkg_path, "resources_file", "tts_output.wav"
|
|
|
)
|
|
|
else:
|
|
|
model_type = "local"
|
|
|
@@ -312,7 +359,7 @@ class CustomActionServer(Node):
|
|
|
"navigation_2": "机器人反馈:执行navigation({point_name})完成",
|
|
|
"navigation_3": "机器人反馈:执行navigation({point_name})失败,目标点不存在",
|
|
|
"navigation_4": "机器人反馈:执行navigation({point_name})失败",
|
|
|
- "get_current_pose_success": "机器人反馈:get_current_pose()成功",
|
|
|
+ "get_current_pose_success": "机器人反馈:get_current_pose({point_name})成功",
|
|
|
"arm_up_done": "机器人反馈:执行arm_up()完成",
|
|
|
"arm_down_done": "机器人反馈:执行arm_down()完成",
|
|
|
"drift_done": "机器人反馈:执行drift()完成",
|
|
|
@@ -351,7 +398,7 @@ class CustomActionServer(Node):
|
|
|
"navigation_2": "Robot feedback: Execute navigation({point_name}) completed",
|
|
|
"navigation_3": "Robot feedback: Execute navigation({point_name}) failed, target does not exist",
|
|
|
"navigation_4": "Robot feedback: Execute navigation({point_name}) failed",
|
|
|
- "get_current_pose_success": "Robot feedback: get_current_pose() succeeded",
|
|
|
+ "get_current_pose_success": "Robot feedback: get_current_pose({point_name}) succeeded",
|
|
|
"arm_up_done": "Robot feedback: Execute arm_up() completed",
|
|
|
"arm_down_done": "Robot feedback: Execute arm_down() completed",
|
|
|
"drift_done": "Robot feedback: Execute drift() completed",
|
|
|
@@ -616,7 +663,7 @@ class CustomActionServer(Node):
|
|
|
def person_approach_event_callback(self, msg):
|
|
|
"""
|
|
|
人物靠近事件回调函数 / Person approach event callback
|
|
|
- 收到事件后播放欢迎语
|
|
|
+ 收到事件后播放欢迎语,并停止人物靠近检测
|
|
|
"""
|
|
|
if not self.welcome_mode:
|
|
|
return
|
|
|
@@ -626,29 +673,53 @@ class CustomActionServer(Node):
|
|
|
if data.get('event') == 'person_approach':
|
|
|
self.get_logger().info(f"Person approach detected: {data}")
|
|
|
|
|
|
+ # 停止人物靠近检测进程 / Stop person_approach detection process
|
|
|
+ if self.process_map['person_approach']['running']:
|
|
|
+ pid = self.process_map['person_approach']['pid']
|
|
|
+ try:
|
|
|
+ # 使用 psutil 杀掉整个进程树(包括子进程)
|
|
|
+ parent = psutil.Process(pid)
|
|
|
+ children = parent.children(recursive=True)
|
|
|
+ for child in children:
|
|
|
+ try:
|
|
|
+ child.terminate()
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+ parent.terminate()
|
|
|
+ self.process_map['person_approach']['running'] = False
|
|
|
+ self.get_logger().info(f"Stopped person_approach node, PID: {pid}, children: {len(children)}")
|
|
|
+ except Exception as e:
|
|
|
+ self.get_logger().warn(f"Failed to stop person_approach: {e}")
|
|
|
+
|
|
|
# 停止上一个 TTS 播放
|
|
|
self.stop_event.set()
|
|
|
time.sleep(0.1)
|
|
|
|
|
|
# 欢迎语内容(后续可修改)
|
|
|
- welcome_text = "欢迎光临"
|
|
|
+ welcome_text = "欢迎光临,有什么可以帮助您的呢?"
|
|
|
|
|
|
# TTS 合成
|
|
|
self.model_client.voice_synthesis(
|
|
|
welcome_text, self.tts_out_path
|
|
|
)
|
|
|
- # 异步播放
|
|
|
- self.play_audio_async(self.tts_out_path)
|
|
|
+ # 同步播放欢迎语,等待播放完成后启动 ASR 监听
|
|
|
+ self.play_audio(self.tts_out_path)
|
|
|
self.get_logger().info(f"Playing welcome TTS: {welcome_text}")
|
|
|
+ # 播放完成,启动 ASR 监听用户对话
|
|
|
+ # 将 welcome_mode 设置为 False,允许对话结束后重新启动迎宾模式
|
|
|
+ self.welcome_mode = False
|
|
|
+ self.asr_control_pub.publish(String(data="start_listen"))
|
|
|
+ self.get_logger().info("Welcome TTS finished, started ASR listening")
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
self.get_logger().error("Failed to parse person_approach event data")
|
|
|
except Exception as e:
|
|
|
self.get_logger().error(f"Error in person_approach_event_callback: {e}")
|
|
|
|
|
|
- def get_current_pose(self):
|
|
|
+ def get_current_pose(self, point_name="zero"):
|
|
|
"""
|
|
|
获取当前在全局地图坐标系下的位置 /Get the current position in the global map coordinate system
|
|
|
+ :param point_name: 点位名称,用于保存到 navpose_dict,默认为 "zero"
|
|
|
"""
|
|
|
# 获取当前目标点坐标
|
|
|
transform = self.tf_buffer.lookup_transform(
|
|
|
@@ -661,16 +732,16 @@ class CustomActionServer(Node):
|
|
|
pose.pose.position.y = transform.transform.translation.y
|
|
|
pose.pose.position.z = 0.0
|
|
|
pose.pose.orientation = transform.transform.rotation
|
|
|
- self.navpose_dict["zero"] = pose
|
|
|
+ self.navpose_dict[point_name] = pose
|
|
|
# 打印记录的坐标
|
|
|
position = pose.pose.position
|
|
|
orientation = pose.pose.orientation
|
|
|
self.get_logger().info(
|
|
|
- f"Recorded Pose - Position: x={position.x}, y={position.y},\
|
|
|
+ f"Recorded Pose [{point_name}] - Position: x={position.x}, y={position.y},\
|
|
|
z={position.z},Orientation: x={orientation.x}, y={orientation.y}, z={orientation.z}, w={orientation.w}"
|
|
|
)
|
|
|
- if not self.interrupt_flag:
|
|
|
- self.action_status_pub("get_current_pose_success")
|
|
|
+ if not self.combination_mode and not self.interrupt_flag:
|
|
|
+ self.action_status_pub("get_current_pose_success", point_name=point_name)
|
|
|
|
|
|
def action_status_pub(self, key, **kwargs):
|
|
|
"""
|
|
|
@@ -741,9 +812,10 @@ class CustomActionServer(Node):
|
|
|
self.result = future_result.result()
|
|
|
self.navigation_finish_flag = True
|
|
|
if self.result.status == 4:
|
|
|
- self.action_status_pub(
|
|
|
- "navigation_2", point_name=point_name
|
|
|
- ) # 执行导航成功 /execute navigation success
|
|
|
+ if not self.combination_mode:
|
|
|
+ self.action_status_pub(
|
|
|
+ "navigation_2", point_name=point_name
|
|
|
+ ) # 执行导航成功 /execute navigation success
|
|
|
|
|
|
elif self.result.status == 5:
|
|
|
self.get_logger().info("Cancel navigation")
|
|
|
@@ -1137,11 +1209,14 @@ class CustomActionServer(Node):
|
|
|
self.action_status_pub("dance_done")
|
|
|
|
|
|
def stop(self): # 停止
|
|
|
- twist = Twist()
|
|
|
- twist.linear.x = 0.0
|
|
|
- twist.linear.y = 0.0
|
|
|
- twist.angular.z = 0.0
|
|
|
- self.publisher.publish(twist)
|
|
|
+ try:
|
|
|
+ twist = Twist()
|
|
|
+ twist.linear.x = 0.0
|
|
|
+ twist.linear.y = 0.0
|
|
|
+ twist.angular.z = 0.0
|
|
|
+ self.publisher.publish(twist)
|
|
|
+ except Exception as e:
|
|
|
+ self.get_logger().warn(f"stop() failed to publish twist: {e}")
|
|
|
|
|
|
def _execute_action(self, twist, num=1, durationtime=3.0):
|
|
|
for _ in range(num):
|
|
|
@@ -1414,9 +1489,11 @@ class CustomActionServer(Node):
|
|
|
goal_handle.request.llm_response is not None
|
|
|
or goal_handle.request.text_response != ""
|
|
|
): # 语音模式,播放对话 # Voice mode, play dialogue
|
|
|
- self.model_client.voice_synthesis(
|
|
|
+ self.get_logger().info(f"[TTS] 开始合成: {goal_handle.request.llm_response[:50]}...")
|
|
|
+ result = self.model_client.voice_synthesis(
|
|
|
goal_handle.request.llm_response, self.tts_out_path
|
|
|
)
|
|
|
+ self.get_logger().info(f"[TTS] 合成完成, result={result}, 路径={self.tts_out_path}")
|
|
|
self.play_audio(self.tts_out_path, feedback=True)
|
|
|
else:
|
|
|
self.action_status_pub("response_done")
|
|
|
@@ -1524,13 +1601,23 @@ class CustomActionServer(Node):
|
|
|
"""
|
|
|
发布 continue_listen 指令,让 ASR 继续监听
|
|
|
"""
|
|
|
+ # 关闭人物靠近检测,避免在多轮对话期间重复触发
|
|
|
+ if self.process_map['person_approach']['running']:
|
|
|
+ pid = self.process_map['person_approach']['pid']
|
|
|
+ try:
|
|
|
+ import os
|
|
|
+ os.kill(pid, signal.SIGTERM)
|
|
|
+ self.process_map['person_approach']['running'] = False
|
|
|
+ self.get_logger().info(f"Stopped person_approach node for dialogue, PID: {pid}")
|
|
|
+ except Exception as e:
|
|
|
+ self.get_logger().warn(f"Failed to stop person_approach: {e}")
|
|
|
+
|
|
|
msg = String()
|
|
|
msg.data = "continue_listen"
|
|
|
self.asr_control_pub.publish(msg)
|
|
|
self.get_logger().info("[多轮对话] ask_user 播放完成,已发布 /asr/control: continue_listen")
|
|
|
|
|
|
- @staticmethod
|
|
|
- def kill_process_tree(pid):
|
|
|
+ def kill_process_tree(self, pid):
|
|
|
try:
|
|
|
parent = psutil.Process(pid)
|
|
|
children = parent.children(recursive=True)
|
|
|
@@ -1564,6 +1651,101 @@ class CustomActionServer(Node):
|
|
|
except psutil.NoSuchProcess:
|
|
|
pass
|
|
|
|
|
|
+ def _on_shutdown(self):
|
|
|
+ """
|
|
|
+ ROS2 节点关闭时的回调,确保清理所有子进程
|
|
|
+ """
|
|
|
+ self.get_logger().info("[Shutdown] Cleaning up child processes...")
|
|
|
+
|
|
|
+ # 取消防抖定时器
|
|
|
+ if self._config_debounce_timer:
|
|
|
+ self._config_debounce_timer.cancel()
|
|
|
+
|
|
|
+ # 清理所有管理的子进程
|
|
|
+ for process_name, process_info in self.process_map.items():
|
|
|
+ if process_info['pid'] is not None:
|
|
|
+ self.get_logger().info(f"[Shutdown] Killing {process_name} (PID: {process_info['pid']})")
|
|
|
+ self.kill_process_tree(process_info['pid'])
|
|
|
+ process_info['pid'] = None
|
|
|
+
|
|
|
+ self.get_logger().info("[Shutdown] All child processes cleaned up")
|
|
|
+
|
|
|
+ def kill_process_tree(self, pid):
|
|
|
+ """
|
|
|
+ 彻底杀死进程及其所有子进程(修复版)
|
|
|
+ """
|
|
|
+ import os
|
|
|
+ import time
|
|
|
+
|
|
|
+ try:
|
|
|
+ parent = psutil.Process(pid)
|
|
|
+ children = parent.children(recursive=True)
|
|
|
+ self.get_logger().info(f"[Shutdown] Found {len(children)} children for PID {pid}")
|
|
|
+
|
|
|
+ # 1. 先向所有子进程发送 SIGTERM
|
|
|
+ for child in children:
|
|
|
+ try:
|
|
|
+ child.terminate()
|
|
|
+ self.get_logger().info(f"[Shutdown] Sent SIGTERM to child PID {child.pid}")
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # 2. 同时向父进程发送 SIGTERM
|
|
|
+ try:
|
|
|
+ parent.terminate()
|
|
|
+ self.get_logger().info(f"[Shutdown] Sent SIGTERM to parent PID {pid}")
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # 3. 等待一小段时间让进程响应
|
|
|
+ time.sleep(0.5)
|
|
|
+
|
|
|
+ # 4. 检查哪些进程还活着,发送 SIGKILL
|
|
|
+ alive = []
|
|
|
+ for child in children:
|
|
|
+ try:
|
|
|
+ if child.is_running():
|
|
|
+ alive.append(child)
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+
|
|
|
+ try:
|
|
|
+ if parent.is_running():
|
|
|
+ alive.append(parent)
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+
|
|
|
+ for p in alive:
|
|
|
+ try:
|
|
|
+ p.kill()
|
|
|
+ self.get_logger().info(f"[Shutdown] Sent SIGKILL to PID {p.pid}")
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ pass
|
|
|
+
|
|
|
+ except psutil.NoSuchProcess:
|
|
|
+ self.get_logger().info(f"[Shutdown] PID {pid} already terminated")
|
|
|
+ except Exception as e:
|
|
|
+ self.get_logger().warn(f"[Shutdown] Error killing process tree: {e}")
|
|
|
+
|
|
|
+ # 5. 额外措施:尝试使用 killpg 杀死进程组(针对 ros2 launch)
|
|
|
+ try:
|
|
|
+ import os
|
|
|
+ pgid = os.getpgid(pid)
|
|
|
+ os.killpg(pgid, signal.SIGKILL)
|
|
|
+ self.get_logger().info(f"[Shutdown] Killed process group {pgid}")
|
|
|
+ except ProcessLookupError:
|
|
|
+ self.get_logger().info(f"[Shutdown] Process group already terminated")
|
|
|
+ except Exception as e:
|
|
|
+ self.get_logger().warn(f"[Shutdown] killpg failed: {e}")
|
|
|
+
|
|
|
+ # 6. 最后手段:使用 ros2 命令停止节点
|
|
|
+ try:
|
|
|
+ subprocess.run(["ros2", "node", "kill", "/person_approach_node"],
|
|
|
+ capture_output=True, timeout=2)
|
|
|
+ self.get_logger().info("[Shutdown] Sent ros2 node kill for person_approach_node")
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
def play_audio(self, file_path: str, feedback: Bool = False) -> None:
|
|
|
"""
|
|
|
同步方式播放音频函数The function for playing audio in synchronous mode
|
|
|
@@ -1575,6 +1757,7 @@ class CustomActionServer(Node):
|
|
|
if pygame.mixer.music.get_busy():
|
|
|
pygame.mixer.music.stop()
|
|
|
self.stop_event.clear()
|
|
|
+ self.get_logger().info(f"play_audio: loading {file_path}")
|
|
|
pygame.mixer.music.load(file_path)
|
|
|
pygame.mixer.music.play()
|
|
|
self.get_logger().info(f"play_audio: started playing {file_path}")
|
|
|
@@ -1660,10 +1843,9 @@ def main(args=None):
|
|
|
try:
|
|
|
executor.spin()
|
|
|
except KeyboardInterrupt:
|
|
|
- custom_action_server.stop()
|
|
|
pass
|
|
|
finally:
|
|
|
- custom_action_server.stop()
|
|
|
+ # 先调用 destroy_node 清理子进程(这会触发 _on_shutdown)
|
|
|
custom_action_server.destroy_node()
|
|
|
executor.shutdown()
|
|
|
rclpy.shutdown()
|