Skip to content

Commit 4545e1c

Browse files
committed
Add monitor UI for voice agent -- uses PyQT plugins instead of standalone app
1 parent ed13ef9 commit 4545e1c

20 files changed

+2843
-0
lines changed

coffee_ws/src/coffee_voice_agent_ui/coffee_voice_agent_ui/__init__.py

Whitespace-only changes.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Coffee Voice Agent Monitor - RQT Plugin for Real-time Monitoring
4+
5+
This RQT plugin provides a comprehensive dashboard for monitoring the Coffee Voice Agent
6+
system including agent status, emotions, tool usage, conversation flow, and analytics.
7+
"""
8+
9+
import os
10+
import rclpy
11+
from rclpy.node import Node
12+
from rclpy.executors import MultiThreadedExecutor
13+
import threading
14+
from datetime import datetime
15+
16+
from rqt_gui_py.plugin import Plugin
17+
from python_qt_binding import loadUi
18+
from python_qt_binding.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout
19+
from python_qt_binding.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot
20+
21+
# ROS2 Messages
22+
from std_msgs.msg import String, Bool
23+
from coffee_voice_agent_msgs.msg import AgentStatus, ToolEvent
24+
25+
# Import custom widgets
26+
from .widgets.agent_status_widget import AgentStatusWidget
27+
from .widgets.emotion_display_widget import EmotionDisplayWidget
28+
from .widgets.conversation_widget import ConversationWidget
29+
from .widgets.tool_monitor_widget import ToolMonitorWidget
30+
from .widgets.analytics_widget import AnalyticsWidget
31+
from .widgets.controls_widget import ControlsWidget
32+
33+
34+
class VoiceAgentMonitorNode(Node):
35+
"""ROS2 Node for handling voice agent monitoring subscriptions"""
36+
37+
# PyQt signals for thread-safe UI updates
38+
agent_status_received = pyqtSignal(AgentStatus)
39+
tool_event_received = pyqtSignal(ToolEvent)
40+
user_speech_received = pyqtSignal(str)
41+
connection_status_received = pyqtSignal(bool)
42+
43+
def __init__(self):
44+
super().__init__('voice_agent_monitor_node')
45+
46+
# Topic subscriptions
47+
self.status_sub = self.create_subscription(
48+
AgentStatus,
49+
'voice_agent/status',
50+
self.status_callback,
51+
10
52+
)
53+
54+
self.tool_sub = self.create_subscription(
55+
ToolEvent,
56+
'voice_agent/tool_events',
57+
self.tool_callback,
58+
10
59+
)
60+
61+
self.speech_sub = self.create_subscription(
62+
String,
63+
'voice_agent/user_speech',
64+
self.speech_callback,
65+
10
66+
)
67+
68+
self.connection_sub = self.create_subscription(
69+
Bool,
70+
'voice_agent/connected',
71+
self.connection_callback,
72+
10
73+
)
74+
75+
# Publishers for sending commands
76+
self.virtual_request_pub = self.create_publisher(
77+
String,
78+
'voice_agent/virtual_requests',
79+
10
80+
)
81+
82+
self.command_pub = self.create_publisher(
83+
String,
84+
'voice_agent/commands',
85+
10
86+
)
87+
88+
self.get_logger().info("Voice Agent Monitor Node initialized")
89+
90+
def status_callback(self, msg):
91+
"""Handle AgentStatus messages"""
92+
self.agent_status_received.emit(msg)
93+
94+
def tool_callback(self, msg):
95+
"""Handle ToolEvent messages"""
96+
self.tool_event_received.emit(msg)
97+
98+
def speech_callback(self, msg):
99+
"""Handle user speech messages"""
100+
self.user_speech_received.emit(msg.data)
101+
102+
def connection_callback(self, msg):
103+
"""Handle connection status messages"""
104+
self.connection_status_received.emit(msg.data)
105+
106+
107+
class VoiceAgentMonitor(Plugin):
108+
"""RQT Plugin for Coffee Voice Agent Monitoring Dashboard"""
109+
110+
def __init__(self, context):
111+
super(VoiceAgentMonitor, self).__init__(context)
112+
113+
# Give QObjects reasonable names
114+
self.setObjectName('VoiceAgentMonitor')
115+
116+
# Create main widget
117+
self._widget = QWidget()
118+
119+
# Set up the UI
120+
self._setup_ui()
121+
122+
# Initialize ROS2 node
123+
self._init_ros()
124+
125+
# Add widget to the user interface
126+
context.add_widget(self._widget)
127+
128+
# Start update timer
129+
self._setup_timers()
130+
131+
def _setup_ui(self):
132+
"""Set up the main UI layout"""
133+
# Main layout - 3x3 grid for dashboard panels
134+
main_layout = QGridLayout()
135+
self._widget.setLayout(main_layout)
136+
137+
# Create dashboard widgets
138+
self.agent_status_widget = AgentStatusWidget()
139+
self.emotion_widget = EmotionDisplayWidget()
140+
self.conversation_widget = ConversationWidget()
141+
self.tool_monitor_widget = ToolMonitorWidget()
142+
self.analytics_widget = AnalyticsWidget()
143+
self.controls_widget = ControlsWidget()
144+
145+
# Arrange widgets in dashboard layout
146+
# Row 0: Agent Status | Conversation Flow | Analytics
147+
main_layout.addWidget(self.agent_status_widget, 0, 0)
148+
main_layout.addWidget(self.conversation_widget, 0, 1)
149+
main_layout.addWidget(self.analytics_widget, 0, 2)
150+
151+
# Row 1: Emotion Display | Tool Monitor | Controls
152+
main_layout.addWidget(self.emotion_widget, 1, 0)
153+
main_layout.addWidget(self.tool_monitor_widget, 1, 1)
154+
main_layout.addWidget(self.controls_widget, 1, 2)
155+
156+
# Set column stretch to make conversation widget wider
157+
main_layout.setColumnStretch(0, 1) # Status/Emotion column
158+
main_layout.setColumnStretch(1, 2) # Conversation/Tools column (wider)
159+
main_layout.setColumnStretch(2, 1) # Analytics/Controls column
160+
161+
self._widget.setWindowTitle('Coffee Voice Agent Monitor')
162+
self._widget.resize(1200, 800)
163+
164+
def _init_ros(self):
165+
"""Initialize ROS2 node and connections"""
166+
# Initialize rclpy if not already done
167+
if not rclpy.ok():
168+
rclpy.init()
169+
170+
# Create ROS2 node
171+
self.ros_node = VoiceAgentMonitorNode()
172+
173+
# Connect ROS signals to UI update methods
174+
self.ros_node.agent_status_received.connect(self._update_agent_status)
175+
self.ros_node.tool_event_received.connect(self._update_tool_event)
176+
self.ros_node.user_speech_received.connect(self._update_user_speech)
177+
self.ros_node.connection_status_received.connect(self._update_connection_status)
178+
179+
# Connect control widget signals to publishers
180+
self.controls_widget.virtual_request_signal.connect(self._send_virtual_request)
181+
self.controls_widget.command_signal.connect(self._send_command)
182+
183+
# Start ROS spinning in separate thread
184+
self.ros_executor = MultiThreadedExecutor()
185+
self.ros_executor.add_node(self.ros_node)
186+
self.ros_thread = threading.Thread(target=self.ros_executor.spin, daemon=True)
187+
self.ros_thread.start()
188+
189+
def _setup_timers(self):
190+
"""Set up update timers for the UI"""
191+
# Main update timer for real-time data
192+
self.update_timer = QTimer()
193+
self.update_timer.timeout.connect(self._periodic_update)
194+
self.update_timer.start(100) # 10 FPS updates
195+
196+
# Analytics update timer (slower)
197+
self.analytics_timer = QTimer()
198+
self.analytics_timer.timeout.connect(self._update_analytics)
199+
self.analytics_timer.start(1000) # 1 FPS for analytics
200+
201+
@pyqtSlot(AgentStatus)
202+
def _update_agent_status(self, status):
203+
"""Update UI with new agent status"""
204+
self.agent_status_widget.update_status(status)
205+
self.emotion_widget.update_emotion(status.emotion, status.previous_emotion)
206+
self.conversation_widget.update_agent_state(status)
207+
208+
@pyqtSlot(ToolEvent)
209+
def _update_tool_event(self, event):
210+
"""Update UI with new tool event"""
211+
self.tool_monitor_widget.add_tool_event(event)
212+
self.conversation_widget.add_tool_event(event)
213+
214+
@pyqtSlot(str)
215+
def _update_user_speech(self, speech):
216+
"""Update UI with user speech"""
217+
self.conversation_widget.add_user_speech(speech)
218+
219+
@pyqtSlot(bool)
220+
def _update_connection_status(self, connected):
221+
"""Update UI with connection status"""
222+
self.agent_status_widget.update_connection(connected)
223+
224+
@pyqtSlot(str)
225+
def _send_virtual_request(self, request_json):
226+
"""Send virtual request through ROS2"""
227+
if self.ros_node:
228+
msg = String()
229+
msg.data = request_json
230+
self.ros_node.virtual_request_pub.publish(msg)
231+
232+
@pyqtSlot(str)
233+
def _send_command(self, command_json):
234+
"""Send command through ROS2"""
235+
if self.ros_node:
236+
msg = String()
237+
msg.data = command_json
238+
self.ros_node.command_pub.publish(msg)
239+
240+
def _periodic_update(self):
241+
"""Periodic UI updates for real-time elements"""
242+
# Update conversation widget timestamps
243+
self.conversation_widget.update_timestamps()
244+
245+
# Update tool monitor timing
246+
self.tool_monitor_widget.update_timing()
247+
248+
# Update emotion transitions
249+
self.emotion_widget.update_animations()
250+
251+
def _update_analytics(self):
252+
"""Update analytics data"""
253+
# Collect data from other widgets for analytics
254+
agent_data = self.agent_status_widget.get_analytics_data()
255+
tool_data = self.tool_monitor_widget.get_analytics_data()
256+
conversation_data = self.conversation_widget.get_analytics_data()
257+
258+
self.analytics_widget.update_analytics(agent_data, tool_data, conversation_data)
259+
260+
def shutdown_plugin(self):
261+
"""Clean up when plugin shuts down"""
262+
if hasattr(self, 'update_timer'):
263+
self.update_timer.stop()
264+
if hasattr(self, 'analytics_timer'):
265+
self.analytics_timer.stop()
266+
267+
if hasattr(self, 'ros_executor'):
268+
self.ros_executor.shutdown()
269+
270+
if hasattr(self, 'ros_thread'):
271+
self.ros_thread.join(timeout=1.0)
272+
273+
274+
def main():
275+
"""Standalone entry point for testing"""
276+
import sys
277+
from python_qt_binding.QtWidgets import QApplication
278+
279+
# Initialize ROS2
280+
rclpy.init()
281+
282+
# Create Qt Application
283+
app = QApplication(sys.argv)
284+
285+
# Create standalone widget
286+
widget = QWidget()
287+
monitor = VoiceAgentMonitor(None)
288+
layout = QVBoxLayout()
289+
layout.addWidget(monitor._widget)
290+
widget.setLayout(layout)
291+
widget.show()
292+
293+
try:
294+
sys.exit(app.exec_())
295+
except KeyboardInterrupt:
296+
pass
297+
finally:
298+
rclpy.shutdown()
299+
300+
301+
if __name__ == '__main__':
302+
main()

0 commit comments

Comments
 (0)