PyQt와 ROS2로 로봇 제어 GUI 만들기: 컴포넌트별 단계 실습

1. 실습 목표

이번 글에서는 PyQt와 ROS2를 이용해서 로봇 제어용 GUI를 단계적으로 만드는 방법을 실습합니다.

처음부터 큰 GUI를 한 번에 작성하면 구조를 이해하기 어렵습니다. 그래서 이번 실습은 PyQt의 기본 위젯을 하나씩 사용해 보고, 마지막에는 ROS2 Publisher와 Subscriber까지 포함한 로봇 제어 GUI를 완성하는 방식으로 진행합니다.

이번 실습에서 다룰 내용은 다음과 같습니다.

  1. PyQt 기본 창 만들기
  2. QLabel로 상태 표시하기
  3. QPushButton으로 버튼 만들기
  4. QLineEdit으로 속도 입력받기
  5. ROS2 Publisher로 /cmd_vel 발행하기
  6. QComboBox로 로봇 모드 선택하기
  7. QCheckBox로 옵션 ON/OFF 제어하기
  8. QSlider와 QSpinBox로 속도 조절하기
  9. QProgressBar로 배터리 표시하기
  10. QListWidget으로 로그 출력하기
  11. QTableWidget으로 위치 데이터 표시하기
  12. ROS2 Subscriber로 GUI 업데이트하기
  13. QTimer로 주기적인 GUI 갱신하기
  14. QThread와 pyqtSignal로 긴 작업 처리하기
  15. 최종 전체 코드 완성하기

실습 환경은 다음을 기준으로 합니다.

Ubuntu 22.04
ROS2 Humble
Python 3
PyQt5

필요한 패키지는 다음 명령으로 설치합니다.

sudo apt update
sudo apt install python3-pyqt5 ros-humble-geometry-msgs ros-humble-std-msgs

ROS2 환경을 사용하기 위해 터미널에서 다음 명령을 먼저 실행합니다.

source /opt/ros/humble/setup.bash

이번 글의 예제는 각 단원별로 독립 실행이 가능하도록 구성했습니다.
즉, 각 단원의 코드를 하나의 Python 파일로 저장해서 바로 실행할 수 있습니다.

2. PyQt 기본 창 만들기

가장 먼저 PyQt의 기본 창을 만들어 보겠습니다.

PyQt GUI 프로그램은 기본적으로 다음 구조를 가집니다.

QApplication 생성
QWidget 또는 QMainWindow 생성
위젯 표시
이벤트 루프 실행

아래 코드를 01_basic_window.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("ROS2 Robot Control GUI")
        self.setGeometry(300, 300, 400, 250)


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 01_basic_window.py

실행하면 빈 GUI 창이 하나 나타납니다.

여기서 중요한 부분은 QApplication입니다.
PyQt 프로그램은 반드시 하나의 QApplication 객체를 가지고 있어야 합니다.

app = QApplication(sys.argv)

그리고 실제 창은 QWidget을 상속받아 만듭니다.

class RobotControlGUI(QWidget):

이제 이 기본 창 위에 버튼, 라벨, 입력창, 슬라이더 등을 하나씩 추가해 보겠습니다.

3. QLabel로 상태 표시하기

이번에는 QLabel을 이용해서 로봇의 상태를 GUI에 표시해 보겠습니다.

QLabel은 텍스트나 이미지를 화면에 표시할 때 사용하는 가장 기본적인 위젯입니다.

아래 코드를 02_label_status.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Status Display")
        self.setGeometry(300, 300, 400, 250)

        self.status_label = QLabel("Robot Status: IDLE")

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)

        self.setLayout(layout)


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 02_label_status.py

화면에 다음 문구가 표시됩니다.

Robot Status: IDLE

QLabel은 로봇의 현재 상태를 표시할 때 자주 사용됩니다.

예를 들면 다음과 같은 정보를 표시할 수 있습니다.

Robot Status: IDLE
Robot Status: MOVING
Robot Status: STOPPED
Robot Status: ERROR
Battery: 85%
Mode: AUTO

실제 로봇 제어 GUI에서는 ROS2 Subscriber로 받은 상태 데이터를 QLabel에 표시하는 방식으로 많이 사용합니다.

4. QPushButton으로 버튼 만들기

이번에는 QPushButton을 사용해서 버튼을 만들어 보겠습니다.

버튼은 로봇 GUI에서 가장 많이 사용하는 입력 방식입니다.

예를 들어 다음과 같은 기능에 사용할 수 있습니다.

Start
Stop
Emergency Stop
Connect
Disconnect
Reset

아래 코드를 03_button_control.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Button Control")
        self.setGeometry(300, 300, 400, 250)

        self.status_label = QLabel("Robot Status: IDLE")

        self.start_button = QPushButton("START")
        self.stop_button = QPushButton("STOP")

        self.start_button.clicked.connect(self.start_robot)
        self.stop_button.clicked.connect(self.stop_robot)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.start_button)
        layout.addWidget(self.stop_button)

        self.setLayout(layout)

    def start_robot(self):
        self.status_label.setText("Robot Status: MOVING")

    def stop_robot(self):
        self.status_label.setText("Robot Status: STOPPED")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 03_button_control.py

START 버튼을 누르면 상태가 MOVING으로 바뀝니다.

STOP 버튼을 누르면 상태가 STOPPED로 바뀝니다.

핵심 코드는 다음 부분입니다.

self.start_button.clicked.connect(self.start_robot)

PyQt에서는 버튼 클릭 같은 이벤트를 signal이라고 합니다.
그리고 그 이벤트가 발생했을 때 실행할 함수를 slot이라고 합니다.

즉, 위 코드는 다음 의미입니다.

START 버튼이 클릭되면 start_robot 함수를 실행하라

5. QLineEdit으로 속도 입력받기

이번에는 QLineEdit을 사용해서 사용자가 직접 로봇 속도를 입력할 수 있도록 만들어 보겠습니다.

QLineEdit은 한 줄짜리 텍스트 입력창입니다.

속도값, 거리값, 목표 좌표, IP 주소, 포트 번호 등을 입력받을 때 사용할 수 있습니다.

아래 코드를 04_lineedit_speed.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QLineEdit, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Speed Input")
        self.setGeometry(300, 300, 400, 250)

        self.status_label = QLabel("Robot Status: IDLE")
        self.speed_input = QLineEdit()
        self.speed_input.setPlaceholderText("Enter linear speed ex) 0.3")

        self.apply_button = QPushButton("Apply Speed")
        self.apply_button.clicked.connect(self.apply_speed)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.speed_input)
        layout.addWidget(self.apply_button)

        self.setLayout(layout)

    def apply_speed(self):
        text = self.speed_input.text()

        try:
            speed = float(text)
            self.status_label.setText(f"Applied Speed: {speed:.2f} m/s")
        except ValueError:
            self.status_label.setText("Invalid speed value")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 04_lineedit_speed.py

입력창에 다음과 같이 입력합니다.

0.5

그리고 Apply Speed 버튼을 누르면 GUI에 다음과 같이 표시됩니다.

Applied Speed: 0.50 m/s

잘못된 값을 입력하면 다음과 같이 표시됩니다.

Invalid speed value

로봇 제어 GUI에서는 사용자가 입력한 값이 잘못되면 실제 로봇이 위험하게 움직일 수 있습니다.

따라서 QLineEdit으로 입력받은 값은 반드시 검증해야 합니다.

try:
    speed = float(text)
except ValueError:
    self.status_label.setText("Invalid speed value")

실무에서는 속도 제한도 넣는 것이 좋습니다.

예를 들면 다음과 같습니다.

if speed < 0.0 or speed > 1.0:
    self.status_label.setText("Speed must be between 0.0 and 1.0")

6. ROS2 Publisher로 /cmd_vel 발행하기

이번에는 PyQt GUI에서 ROS2 Publisher를 사용해 /cmd_vel 토픽을 발행해 보겠습니다.

/cmd_vel은 모바일 로봇에서 가장 많이 사용하는 속도 명령 토픽입니다.

일반적으로 메시지 타입은 다음을 사용합니다.

geometry_msgs/msg/Twist

구조는 다음과 같습니다.

linear.x
linear.y
linear.z
angular.x
angular.y
angular.z

일반적인 2D 모바일 로봇에서는 주로 다음 두 값만 사용합니다.

linear.x
angular.z

아래 코드를 05_ros2_cmd_vel_publisher.py 파일로 저장합니다.

import sys

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist

from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QLineEdit, QVBoxLayout
from PyQt5.QtCore import QTimer


class CmdVelPublisher(Node):
    def __init__(self):
        super().__init__("pyqt_cmd_vel_publisher")
        self.publisher = self.create_publisher(Twist, "/cmd_vel", 10)

    def publish_cmd_vel(self, linear_x, angular_z):
        msg = Twist()
        msg.linear.x = linear_x
        msg.angular.z = angular_z

        self.publisher.publish(msg)
        self.get_logger().info(f"Published cmd_vel: linear.x={linear_x}, angular.z={angular_z}")


class RobotControlGUI(QWidget):
    def __init__(self, ros_node):
        super().__init__()

        self.ros_node = ros_node

        self.setWindowTitle("ROS2 /cmd_vel Publisher GUI")
        self.setGeometry(300, 300, 400, 300)

        self.status_label = QLabel("Robot Status: IDLE")

        self.linear_input = QLineEdit()
        self.linear_input.setPlaceholderText("Linear speed ex) 0.3")

        self.angular_input = QLineEdit()
        self.angular_input.setPlaceholderText("Angular speed ex) 0.5")

        self.publish_button = QPushButton("Publish /cmd_vel")
        self.stop_button = QPushButton("STOP")

        self.publish_button.clicked.connect(self.publish_velocity)
        self.stop_button.clicked.connect(self.stop_robot)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.linear_input)
        layout.addWidget(self.angular_input)
        layout.addWidget(self.publish_button)
        layout.addWidget(self.stop_button)

        self.setLayout(layout)

        self.ros_timer = QTimer()
        self.ros_timer.timeout.connect(self.spin_ros)
        self.ros_timer.start(10)

    def spin_ros(self):
        rclpy.spin_once(self.ros_node, timeout_sec=0)

    def publish_velocity(self):
        try:
            linear_x = float(self.linear_input.text())
            angular_z = float(self.angular_input.text())

            self.ros_node.publish_cmd_vel(linear_x, angular_z)
            self.status_label.setText(f"Published: linear={linear_x:.2f}, angular={angular_z:.2f}")

        except ValueError:
            self.status_label.setText("Invalid velocity value")

    def stop_robot(self):
        self.ros_node.publish_cmd_vel(0.0, 0.0)
        self.status_label.setText("Robot Status: STOPPED")


def main():
    rclpy.init()

    ros_node = CmdVelPublisher()

    app = QApplication(sys.argv)
    window = RobotControlGUI(ros_node)
    window.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == "__main__":
    main()

실행하기 전에 터미널 1에서 ROS2 환경을 설정합니다.

source /opt/ros/humble/setup.bash

터미널 1에서 GUI를 실행합니다.

python3 05_ros2_cmd_vel_publisher.py

터미널 2에서 /cmd_vel 토픽을 확인합니다.

source /opt/ros/humble/setup.bash
ros2 topic echo /cmd_vel

GUI에서 다음 값을 입력합니다.

Linear speed: 0.3
Angular speed: 0.0

Publish /cmd_vel 버튼을 누르면 터미널 2에서 다음과 비슷한 메시지를 확인할 수 있습니다.

linear:
  x: 0.3
angular:
  z: 0.0

여기서 중요한 부분은 QTimer입니다.

self.ros_timer = QTimer()
self.ros_timer.timeout.connect(self.spin_ros)
self.ros_timer.start(10)

PyQt도 자체 이벤트 루프가 있고, ROS2도 자체 이벤트 루프가 있습니다.
그래서 GUI 프로그램 안에서 ROS2를 같이 사용할 때는 rclpy.spin_once()를 주기적으로 호출하는 방식이 편합니다.

rclpy.spin_once(self.ros_node, timeout_sec=0)

7. QComboBox로 로봇 모드 선택하기

이번에는 QComboBox를 사용해서 로봇의 동작 모드를 선택하는 GUI를 만들어 보겠습니다.

QComboBox는 드롭다운 선택 박스입니다.

로봇 제어 GUI에서는 다음과 같은 모드 선택에 사용할 수 있습니다.

MANUAL
AUTO
MAPPING
DOCKING
EMERGENCY

아래 코드를 06_combobox_mode.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QComboBox, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Mode Selector")
        self.setGeometry(300, 300, 400, 250)

        self.status_label = QLabel("Current Mode: MANUAL")

        self.mode_combo = QComboBox()
        self.mode_combo.addItem("MANUAL")
        self.mode_combo.addItem("AUTO")
        self.mode_combo.addItem("MAPPING")
        self.mode_combo.addItem("DOCKING")
        self.mode_combo.addItem("EMERGENCY")

        self.mode_combo.currentTextChanged.connect(self.change_mode)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.mode_combo)

        self.setLayout(layout)

    def change_mode(self, mode):
        self.status_label.setText(f"Current Mode: {mode}")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 06_combobox_mode.py

드롭다운 메뉴에서 모드를 선택하면 상태 라벨이 변경됩니다.

핵심 코드는 다음입니다.

self.mode_combo.currentTextChanged.connect(self.change_mode)

선택된 텍스트가 바뀔 때마다 change_mode() 함수가 실행됩니다.

실제 ROS2 로봇 GUI에서는 선택한 모드를 ROS2 토픽이나 서비스로 보낼 수 있습니다.

예를 들면 다음과 같은 방식으로 확장할 수 있습니다.

/mode_cmd 토픽으로 MANUAL, AUTO, DOCKING 등의 문자열 발행

8. QCheckBox로 옵션 ON/OFF 제어하기

이번에는 QCheckBox를 사용해서 옵션을 ON/OFF 하는 GUI를 만들어 보겠습니다.

QCheckBox는 로봇 기능을 켜고 끌 때 사용하기 좋습니다.

예를 들면 다음과 같습니다.

Obstacle Avoidance ON/OFF
Sensor Logging ON/OFF
Camera Streaming ON/OFF
Auto Docking ON/OFF
Low Speed Mode ON/OFF

아래 코드를 07_checkbox_options.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QCheckBox, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Option CheckBox")
        self.setGeometry(300, 300, 400, 300)

        self.status_label = QLabel("Options: None")

        self.obstacle_checkbox = QCheckBox("Obstacle Avoidance")
        self.logging_checkbox = QCheckBox("Sensor Logging")
        self.camera_checkbox = QCheckBox("Camera Streaming")

        self.obstacle_checkbox.stateChanged.connect(self.update_options)
        self.logging_checkbox.stateChanged.connect(self.update_options)
        self.camera_checkbox.stateChanged.connect(self.update_options)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.obstacle_checkbox)
        layout.addWidget(self.logging_checkbox)
        layout.addWidget(self.camera_checkbox)

        self.setLayout(layout)

    def update_options(self):
        options = []

        if self.obstacle_checkbox.isChecked():
            options.append("Obstacle Avoidance")

        if self.logging_checkbox.isChecked():
            options.append("Sensor Logging")

        if self.camera_checkbox.isChecked():
            options.append("Camera Streaming")

        if options:
            self.status_label.setText("Options: " + ", ".join(options))
        else:
            self.status_label.setText("Options: None")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 07_checkbox_options.py

체크박스를 선택하면 현재 선택된 옵션이 라벨에 표시됩니다.

핵심 코드는 다음입니다.

if self.obstacle_checkbox.isChecked():

isChecked()는 체크박스가 선택되었는지 확인하는 함수입니다.

실제 로봇 GUI에서는 체크박스 상태를 ROS2 파라미터, 토픽, 서비스와 연결할 수 있습니다.

예를 들어 장애물 회피 기능을 켜고 끄는 토픽을 만든다면 다음과 같이 설계할 수 있습니다.

/obstacle_avoidance_enable
std_msgs/msg/Bool

9. QSlider와 QSpinBox로 속도 조절하기

이번에는 QSliderQSpinBox를 함께 사용해서 속도를 조절하는 GUI를 만들어 보겠습니다.

QSlider는 마우스로 값을 조절할 수 있는 슬라이더입니다.
QSpinBox는 숫자를 직접 입력하거나 화살표로 조절할 수 있는 박스입니다.

두 위젯을 연결하면 사용성이 좋아집니다.

아래 코드를 08_slider_spinbox_speed.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QSlider, QSpinBox, QVBoxLayout
from PyQt5.QtCore import Qt


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Speed Slider")
        self.setGeometry(300, 300, 400, 300)

        self.status_label = QLabel("Speed: 0 %")

        self.speed_slider = QSlider(Qt.Horizontal)
        self.speed_slider.setMinimum(0)
        self.speed_slider.setMaximum(100)
        self.speed_slider.setValue(0)

        self.speed_spinbox = QSpinBox()
        self.speed_spinbox.setMinimum(0)
        self.speed_spinbox.setMaximum(100)
        self.speed_spinbox.setValue(0)

        self.speed_slider.valueChanged.connect(self.speed_spinbox.setValue)
        self.speed_spinbox.valueChanged.connect(self.speed_slider.setValue)

        self.speed_slider.valueChanged.connect(self.update_speed_label)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.speed_slider)
        layout.addWidget(self.speed_spinbox)

        self.setLayout(layout)

    def update_speed_label(self, value):
        self.status_label.setText(f"Speed: {value} %")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 08_slider_spinbox_speed.py

슬라이더를 움직이면 QSpinBox 값도 같이 바뀝니다.

반대로 QSpinBox 값을 바꾸면 슬라이더 위치도 같이 바뀝니다.

핵심 코드는 다음입니다.

self.speed_slider.valueChanged.connect(self.speed_spinbox.setValue)
self.speed_spinbox.valueChanged.connect(self.speed_slider.setValue)

이 구조는 로봇 속도 제한값을 조절할 때 유용합니다.

예를 들어 슬라이더 값을 실제 속도로 변환하면 다음과 같습니다.

speed_percent = value
max_speed = 1.0
linear_speed = max_speed * speed_percent / 100.0

즉, 슬라이더 값이 50이면 실제 속도는 0.5 m/s가 됩니다.

10. QProgressBar로 배터리 표시하기

이번에는 QProgressBar를 사용해서 배터리 잔량을 표시해 보겠습니다.

QProgressBar는 진행률이나 상태량을 시각적으로 보여줄 때 사용합니다.

로봇 GUI에서는 다음 정보를 표시할 때 자주 사용됩니다.

Battery
CPU Usage
Memory Usage
Mission Progress
Charging Status

아래 코드를 09_progressbar_battery.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QProgressBar, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Battery Display")
        self.setGeometry(300, 300, 400, 250)

        self.battery_value = 100

        self.status_label = QLabel("Battery: 100 %")

        self.battery_bar = QProgressBar()
        self.battery_bar.setMinimum(0)
        self.battery_bar.setMaximum(100)
        self.battery_bar.setValue(self.battery_value)

        self.consume_button = QPushButton("Consume Battery")
        self.charge_button = QPushButton("Charge Battery")

        self.consume_button.clicked.connect(self.consume_battery)
        self.charge_button.clicked.connect(self.charge_battery)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.battery_bar)
        layout.addWidget(self.consume_button)
        layout.addWidget(self.charge_button)

        self.setLayout(layout)

    def consume_battery(self):
        self.battery_value -= 10

        if self.battery_value < 0:
            self.battery_value = 0

        self.update_battery_display()

    def charge_battery(self):
        self.battery_value += 10

        if self.battery_value > 100:
            self.battery_value = 100

        self.update_battery_display()

    def update_battery_display(self):
        self.battery_bar.setValue(self.battery_value)
        self.status_label.setText(f"Battery: {self.battery_value} %")


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 09_progressbar_battery.py

Consume Battery 버튼을 누르면 배터리 값이 감소합니다.

Charge Battery 버튼을 누르면 배터리 값이 증가합니다.

실제 로봇에서는 배터리 상태를 ROS2 토픽으로 받을 수 있습니다.

예를 들면 다음과 같은 토픽을 사용할 수 있습니다.

/battery_state
sensor_msgs/msg/BatteryState

또는 간단하게 배터리 퍼센트만 받고 싶다면 다음처럼 구성할 수도 있습니다.

/battery_percent
std_msgs/msg/Int32

11. QListWidget으로 로그 출력하기

이번에는 QListWidget을 사용해서 로봇 로그를 GUI에 출력해 보겠습니다.

QListWidget은 여러 줄의 항목을 리스트 형태로 보여주는 위젯입니다.

로봇 GUI에서는 다음과 같은 로그를 출력할 때 유용합니다.

Robot connected
Mission started
Waypoint reached
Obstacle detected
Emergency stop pressed
Battery low

아래 코드를 10_listwidget_log.py 파일로 저장합니다.

import sys
from datetime import datetime

from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QListWidget, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Log Viewer")
        self.setGeometry(300, 300, 500, 300)

        self.log_list = QListWidget()

        self.start_button = QPushButton("Add Start Log")
        self.stop_button = QPushButton("Add Stop Log")
        self.clear_button = QPushButton("Clear Logs")

        self.start_button.clicked.connect(lambda: self.add_log("Robot started"))
        self.stop_button.clicked.connect(lambda: self.add_log("Robot stopped"))
        self.clear_button.clicked.connect(self.log_list.clear)

        layout = QVBoxLayout()
        layout.addWidget(self.log_list)
        layout.addWidget(self.start_button)
        layout.addWidget(self.stop_button)
        layout.addWidget(self.clear_button)

        self.setLayout(layout)

    def add_log(self, message):
        now = datetime.now().strftime("%H:%M:%S")
        self.log_list.addItem(f"[{now}] {message}")
        self.log_list.scrollToBottom()


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

lambda: self.add_log("Robot started")

lambda는 이름 없는 간단한 함수입니다.
이 코드는 아래 함수와 거의 같습니다.

def temp_function():
self.add_log("Robot started")


즉 버튼이 눌리면 다음 함수가 실행됩니다.

self.add_log("Robot started")

실행합니다.

python3 10_listwidget_log.py

버튼을 누를 때마다 로그가 리스트에 추가됩니다.

핵심 코드는 다음입니다.

self.log_list.addItem(f"[{now}] {message}")

그리고 로그가 많아졌을 때 자동으로 가장 아래쪽을 보여주려면 다음 코드를 사용합니다.

self.log_list.scrollToBottom()

실제 로봇 GUI에서는 ROS2 Subscriber로 받은 상태 메시지나 이벤트 메시지를 이 로그창에 출력하면 좋습니다.

12. QTableWidget으로 위치 데이터 표시하기

이번에는 QTableWidget을 사용해서 로봇 위치 데이터를 표 형태로 표시해 보겠습니다.

QTableWidget은 행과 열을 가진 테이블 위젯입니다.

로봇 GUI에서는 다음 정보를 표시할 때 사용할 수 있습니다.

x position
y position
yaw
linear velocity
angular velocity
battery
mode

아래 코드를 11_tablewidget_position.py 파일로 저장합니다.

import sys
from PyQt5.QtWidgets import QApplication, QWidget, QTableWidget, QTableWidgetItem, QPushButton, QVBoxLayout


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Robot Position Table")
        self.setGeometry(300, 300, 500, 300)

        self.table = QTableWidget()
        self.table.setRowCount(3)
        self.table.setColumnCount(2)

        self.table.setHorizontalHeaderLabels(["Name", "Value"])

        self.table.setItem(0, 0, QTableWidgetItem("X Position"))
        self.table.setItem(1, 0, QTableWidgetItem("Y Position"))
        self.table.setItem(2, 0, QTableWidgetItem("Yaw"))

        self.table.setItem(0, 1, QTableWidgetItem("0.00"))
        self.table.setItem(1, 1, QTableWidgetItem("0.00"))
        self.table.setItem(2, 1, QTableWidgetItem("0.00"))

        self.update_button = QPushButton("Update Position")
        self.update_button.clicked.connect(self.update_position)

        layout = QVBoxLayout()
        layout.addWidget(self.table)
        layout.addWidget(self.update_button)

        self.setLayout(layout)

        self.x = 0.0
        self.y = 0.0
        self.yaw = 0.0

    def update_position(self):
        self.x += 0.1
        self.y += 0.2
        self.yaw += 1.0

        self.table.setItem(0, 1, QTableWidgetItem(f"{self.x:.2f}"))
        self.table.setItem(1, 1, QTableWidgetItem(f"{self.y:.2f}"))
        self.table.setItem(2, 1, QTableWidgetItem(f"{self.yaw:.2f}"))


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 11_tablewidget_position.py

Update Position 버튼을 누르면 위치 데이터가 변경됩니다.

핵심 코드는 다음입니다.

self.table.setItem(0, 1, QTableWidgetItem(f"{self.x:.2f}"))

실제 로봇에서는 /odom, /robot_pose, /vehicle_odometry 같은 토픽을 구독해서 테이블을 업데이트할 수 있습니다.

13. ROS2 Subscriber로 GUI 업데이트하기

이번에는 ROS2 Subscriber를 사용해서 GUI를 업데이트해 보겠습니다.

예제에서는 /robot_status라는 토픽을 구독합니다.

메시지 타입은 간단하게 다음을 사용합니다.

std_msgs/msg/String

먼저 GUI Subscriber 코드를 작성합니다.

아래 코드를 12_ros2_subscriber_gui.py 파일로 저장합니다.

import sys

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QListWidget, QVBoxLayout
from PyQt5.QtCore import QTimer


class RobotStatusSubscriber(Node):
    def __init__(self, gui):
        super().__init__("pyqt_robot_status_subscriber")
        self.gui = gui

        self.subscription = self.create_subscription(
            String,
            "/robot_status",
            self.status_callback,
            10
        )

    def status_callback(self, msg):
        self.gui.update_status(msg.data)


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("ROS2 Subscriber GUI")
        self.setGeometry(300, 300, 500, 300)

        self.status_label = QLabel("Robot Status: Waiting...")
        self.log_list = QListWidget()

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.log_list)

        self.setLayout(layout)

    def update_status(self, status):
        self.status_label.setText(f"Robot Status: {status}")
        self.log_list.addItem(status)
        self.log_list.scrollToBottom()


def main():
    rclpy.init()

    app = QApplication(sys.argv)

    gui = RobotControlGUI()
    ros_node = RobotStatusSubscriber(gui)

    timer = QTimer()
    timer.timeout.connect(lambda: rclpy.spin_once(ros_node, timeout_sec=0))
    timer.start(10)

    gui.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == "__main__":
    main()

실행합니다.

python3 12_ros2_subscriber_gui.py

다른 터미널에서 다음 명령으로 토픽을 발행합니다.

source /opt/ros/humble/setup.bash
ros2 topic pub /robot_status std_msgs/msg/String "{data: 'MOVING'}"

GUI에 다음 상태가 표시됩니다.

Robot Status: MOVING

다른 메시지도 보내봅니다.

ros2 topic pub /robot_status std_msgs/msg/String "{data: 'OBSTACLE DETECTED'}"

GUI 상태 라벨과 로그 리스트가 함께 업데이트됩니다.

이 구조는 실제 로봇 GUI에서 매우 중요합니다.

로봇 내부 노드는 센서 상태, 주행 상태, 에러 상태를 ROS2 토픽으로 발행합니다.
GUI는 이 토픽을 구독해서 화면을 업데이트합니다.

14. QTimer로 주기적인 GUI 갱신하기

이번에는 QTimer를 사용해서 GUI를 주기적으로 갱신해 보겠습니다.

QTimer는 일정 시간마다 특정 함수를 실행하는 PyQt 기능입니다.

로봇 GUI에서는 다음 용도로 사용할 수 있습니다.

현재 시간 표시
배터리 값 주기 갱신
ROS2 spin_once 주기 호출
센서 상태 주기 확인
화면 자동 업데이트

아래 코드를 13_qtimer_update.py 파일로 저장합니다.

import sys
from datetime import datetime

from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QProgressBar, QVBoxLayout
from PyQt5.QtCore import QTimer


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("QTimer GUI Update")
        self.setGeometry(300, 300, 400, 250)

        self.time_label = QLabel("Time: -")
        self.battery_label = QLabel("Battery: 100 %")

        self.battery_bar = QProgressBar()
        self.battery_bar.setMinimum(0)
        self.battery_bar.setMaximum(100)
        self.battery_bar.setValue(100)

        self.battery = 100

        layout = QVBoxLayout()
        layout.addWidget(self.time_label)
        layout.addWidget(self.battery_label)
        layout.addWidget(self.battery_bar)

        self.setLayout(layout)

        self.timer = QTimer()
        self.timer.timeout.connect(self.update_gui)
        self.timer.start(1000)

    def update_gui(self):
        now = datetime.now().strftime("%H:%M:%S")
        self.time_label.setText(f"Time: {now}")

        self.battery -= 1

        if self.battery < 0:
            self.battery = 100

        self.battery_label.setText(f"Battery: {self.battery} %")
        self.battery_bar.setValue(self.battery)


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 13_qtimer_update.py

1초마다 시간이 갱신되고 배터리 값이 감소합니다.

핵심 코드는 다음입니다.

self.timer = QTimer()
self.timer.timeout.connect(self.update_gui)
self.timer.start(1000)

start(1000)은 1000ms, 즉 1초마다 실행하라는 뜻입니다.

ROS2와 PyQt를 함께 사용할 때도 QTimer는 매우 중요합니다.

timer.timeout.connect(lambda: rclpy.spin_once(node, timeout_sec=0))

이 구조를 사용하면 GUI가 멈추지 않고 ROS2 메시지도 처리할 수 있습니다.

15. QThread와 pyqtSignal로 긴 작업 처리하기

이번에는 QThreadpyqtSignal을 사용해서 시간이 오래 걸리는 작업을 처리해 보겠습니다.

GUI 프로그램에서 시간이 오래 걸리는 작업을 메인 스레드에서 실행하면 창이 멈춘 것처럼 보입니다.

예를 들면 다음과 같은 작업입니다.

지도 저장
대용량 로그 분석
카메라 영상 처리
AI 추론
파일 업로드
장시간 로봇 점검

이런 작업은 별도 스레드에서 처리해야 합니다.

아래 코드를 14_qthread_worker.py 파일로 저장합니다.

import sys
import time

from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QPushButton, QProgressBar, QVBoxLayout
from PyQt5.QtCore import QThread, pyqtSignal


class LongTaskWorker(QThread):
    progress_signal = pyqtSignal(int)
    status_signal = pyqtSignal(str)

    def run(self):
        self.status_signal.emit("Long task started")

        for i in range(101):
            time.sleep(0.05)
            self.progress_signal.emit(i)

        self.status_signal.emit("Long task finished")


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("QThread Long Task Example")
        self.setGeometry(300, 300, 400, 250)

        self.status_label = QLabel("Status: Ready")

        self.progress_bar = QProgressBar()
        self.progress_bar.setMinimum(0)
        self.progress_bar.setMaximum(100)

        self.start_button = QPushButton("Start Long Task")
        self.start_button.clicked.connect(self.start_long_task)

        layout = QVBoxLayout()
        layout.addWidget(self.status_label)
        layout.addWidget(self.progress_bar)
        layout.addWidget(self.start_button)

        self.setLayout(layout)

        self.worker = None

    def start_long_task(self):
        self.start_button.setEnabled(False)

        self.worker = LongTaskWorker()
        self.worker.progress_signal.connect(self.progress_bar.setValue)
        self.worker.status_signal.connect(self.status_label.setText)
        self.worker.finished.connect(self.task_finished)
        self.worker.start()

    def task_finished(self):
        self.start_button.setEnabled(True)


def main():
    app = QApplication(sys.argv)

    window = RobotControlGUI()
    window.show()

    sys.exit(app.exec_())


if __name__ == "__main__":
    main()

실행합니다.

python3 14_qthread_worker.py

Start Long Task 버튼을 누르면 진행률이 올라갑니다.

이때 GUI 창은 멈추지 않습니다.

핵심 구조는 다음입니다.

class LongTaskWorker(QThread):
    progress_signal = pyqtSignal(int)
    status_signal = pyqtSignal(str)

스레드 내부에서는 GUI 위젯을 직접 수정하지 않는 것이 안전합니다.

대신 pyqtSignal을 사용해서 메인 GUI로 값을 전달합니다.

self.progress_signal.emit(i)

GUI 쪽에서는 이 신호를 받아서 화면을 업데이트합니다.

self.worker.progress_signal.connect(self.progress_bar.setValue)

이 구조는 ROS2 로봇 GUI에서도 중요합니다.

특히 카메라 처리, 지도 처리, AI 추론, 긴 서비스 호출 같은 작업은 메인 GUI 스레드에서 바로 실행하면 안 됩니다.

16. 최종 전체 코드 완성하기

이제 지금까지 실습한 내용을 하나로 합쳐서 로봇 제어 GUI를 만들어 보겠습니다.

최종 GUI에는 다음 기능이 들어갑니다.

로봇 상태 표시
/cmd_vel 발행
속도 입력
모드 선택
옵션 ON/OFF
속도 슬라이더
배터리 표시
로그 출력
위치 테이블 표시
ROS2 Subscriber로 상태 수신
QTimer로 ROS2 spin_once 처리
QThread로 긴 작업 처리

아래 코드를 15_final_robot_control_gui.py 파일로 저장합니다.

import sys
import time
from datetime import datetime

import rclpy
from rclpy.node import Node

from geometry_msgs.msg import Twist
from std_msgs.msg import String, Int32

from PyQt5.QtWidgets import (
    QApplication,
    QWidget,
    QLabel,
    QPushButton,
    QLineEdit,
    QComboBox,
    QCheckBox,
    QSlider,
    QSpinBox,
    QProgressBar,
    QListWidget,
    QTableWidget,
    QTableWidgetItem,
    QVBoxLayout,
    QHBoxLayout,
    QGroupBox
)
from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal


class LongTaskWorker(QThread):
    progress_signal = pyqtSignal(int)
    status_signal = pyqtSignal(str)

    def run(self):
        self.status_signal.emit("System check started")

        for i in range(101):
            time.sleep(0.03)
            self.progress_signal.emit(i)

        self.status_signal.emit("System check finished")


class RobotRosNode(Node):
    def __init__(self, gui):
        super().__init__("pyqt_robot_control_gui_node")

        self.gui = gui

        self.cmd_vel_pub = self.create_publisher(Twist, "/cmd_vel", 10)
        self.mode_pub = self.create_publisher(String, "/robot_mode", 10)
        self.option_pub = self.create_publisher(String, "/robot_options", 10)

        self.status_sub = self.create_subscription(
            String,
            "/robot_status",
            self.status_callback,
            10
        )

        self.battery_sub = self.create_subscription(
            Int32,
            "/battery_percent",
            self.battery_callback,
            10
        )

    def publish_cmd_vel(self, linear_x, angular_z):
        msg = Twist()
        msg.linear.x = linear_x
        msg.angular.z = angular_z

        self.cmd_vel_pub.publish(msg)

    def publish_mode(self, mode):
        msg = String()
        msg.data = mode

        self.mode_pub.publish(msg)

    def publish_options(self, options_text):
        msg = String()
        msg.data = options_text

        self.option_pub.publish(msg)

    def status_callback(self, msg):
        self.gui.update_robot_status(msg.data)

    def battery_callback(self, msg):
        self.gui.update_battery(msg.data)


class RobotControlGUI(QWidget):
    def __init__(self):
        super().__init__()

        self.ros_node = None
        self.worker = None

        self.x = 0.0
        self.y = 0.0
        self.yaw = 0.0

        self.setWindowTitle("Final ROS2 PyQt Robot Control GUI")
        self.setGeometry(200, 200, 900, 700)

        self.create_widgets()
        self.create_layout()
        self.connect_signals()

        self.gui_timer = QTimer()
        self.gui_timer.timeout.connect(self.update_position_demo)
        self.gui_timer.start(1000)

    def set_ros_node(self, ros_node):
        self.ros_node = ros_node

    def create_widgets(self):
        self.status_label = QLabel("Robot Status: Waiting...")
        self.time_label = QLabel("Time: -")

        self.linear_input = QLineEdit()
        self.linear_input.setPlaceholderText("Linear speed ex) 0.3")

        self.angular_input = QLineEdit()
        self.angular_input.setPlaceholderText("Angular speed ex) 0.0")

        self.publish_button = QPushButton("Publish /cmd_vel")
        self.stop_button = QPushButton("STOP")

        self.mode_combo = QComboBox()
        self.mode_combo.addItems(["MANUAL", "AUTO", "MAPPING", "DOCKING", "EMERGENCY"])

        self.obstacle_checkbox = QCheckBox("Obstacle Avoidance")
        self.logging_checkbox = QCheckBox("Sensor Logging")
        self.camera_checkbox = QCheckBox("Camera Streaming")

        self.speed_slider = QSlider(Qt.Horizontal)
        self.speed_slider.setMinimum(0)
        self.speed_slider.setMaximum(100)
        self.speed_slider.setValue(0)

        self.speed_spinbox = QSpinBox()
        self.speed_spinbox.setMinimum(0)
        self.speed_spinbox.setMaximum(100)
        self.speed_spinbox.setValue(0)

        self.battery_label = QLabel("Battery: 100 %")

        self.battery_bar = QProgressBar()
        self.battery_bar.setMinimum(0)
        self.battery_bar.setMaximum(100)
        self.battery_bar.setValue(100)

        self.log_list = QListWidget()

        self.position_table = QTableWidget()
        self.position_table.setRowCount(3)
        self.position_table.setColumnCount(2)
        self.position_table.setHorizontalHeaderLabels(["Name", "Value"])

        self.position_table.setItem(0, 0, QTableWidgetItem("X Position"))
        self.position_table.setItem(1, 0, QTableWidgetItem("Y Position"))
        self.position_table.setItem(2, 0, QTableWidgetItem("Yaw"))

        self.position_table.setItem(0, 1, QTableWidgetItem("0.00"))
        self.position_table.setItem(1, 1, QTableWidgetItem("0.00"))
        self.position_table.setItem(2, 1, QTableWidgetItem("0.00"))

        self.task_status_label = QLabel("Task Status: Ready")

        self.task_progress_bar = QProgressBar()
        self.task_progress_bar.setMinimum(0)
        self.task_progress_bar.setMaximum(100)

        self.task_button = QPushButton("Start System Check")

    def create_layout(self):
        main_layout = QVBoxLayout()

        status_group = QGroupBox("Robot Status")
        status_layout = QVBoxLayout()
        status_layout.addWidget(self.status_label)
        status_layout.addWidget(self.time_label)
        status_group.setLayout(status_layout)

        velocity_group = QGroupBox("Velocity Control")
        velocity_layout = QVBoxLayout()
        velocity_layout.addWidget(self.linear_input)
        velocity_layout.addWidget(self.angular_input)
        velocity_layout.addWidget(self.publish_button)
        velocity_layout.addWidget(self.stop_button)
        velocity_group.setLayout(velocity_layout)

        mode_group = QGroupBox("Mode Control")
        mode_layout = QVBoxLayout()
        mode_layout.addWidget(self.mode_combo)
        mode_group.setLayout(mode_layout)

        option_group = QGroupBox("Options")
        option_layout = QVBoxLayout()
        option_layout.addWidget(self.obstacle_checkbox)
        option_layout.addWidget(self.logging_checkbox)
        option_layout.addWidget(self.camera_checkbox)
        option_group.setLayout(option_layout)

        speed_group = QGroupBox("Speed Limit")
        speed_layout = QVBoxLayout()
        speed_layout.addWidget(self.speed_slider)
        speed_layout.addWidget(self.speed_spinbox)
        speed_group.setLayout(speed_layout)

        battery_group = QGroupBox("Battery")
        battery_layout = QVBoxLayout()
        battery_layout.addWidget(self.battery_label)
        battery_layout.addWidget(self.battery_bar)
        battery_group.setLayout(battery_layout)

        position_group = QGroupBox("Position")
        position_layout = QVBoxLayout()
        position_layout.addWidget(self.position_table)
        position_group.setLayout(position_layout)

        task_group = QGroupBox("Long Task")
        task_layout = QVBoxLayout()
        task_layout.addWidget(self.task_status_label)
        task_layout.addWidget(self.task_progress_bar)
        task_layout.addWidget(self.task_button)
        task_group.setLayout(task_layout)

        log_group = QGroupBox("Log")
        log_layout = QVBoxLayout()
        log_layout.addWidget(self.log_list)
        log_group.setLayout(log_layout)

        row1 = QHBoxLayout()
        row1.addWidget(status_group)
        row1.addWidget(battery_group)

        row2 = QHBoxLayout()
        row2.addWidget(velocity_group)
        row2.addWidget(mode_group)

        row3 = QHBoxLayout()
        row3.addWidget(option_group)
        row3.addWidget(speed_group)

        row4 = QHBoxLayout()
        row4.addWidget(position_group)
        row4.addWidget(task_group)

        main_layout.addLayout(row1)
        main_layout.addLayout(row2)
        main_layout.addLayout(row3)
        main_layout.addLayout(row4)
        main_layout.addWidget(log_group)

        self.setLayout(main_layout)

    def connect_signals(self):
        self.publish_button.clicked.connect(self.publish_velocity)
        self.stop_button.clicked.connect(self.stop_robot)

        self.mode_combo.currentTextChanged.connect(self.change_mode)

        self.obstacle_checkbox.stateChanged.connect(self.update_options)
        self.logging_checkbox.stateChanged.connect(self.update_options)
        self.camera_checkbox.stateChanged.connect(self.update_options)

        self.speed_slider.valueChanged.connect(self.speed_spinbox.setValue)
        self.speed_spinbox.valueChanged.connect(self.speed_slider.setValue)
        self.speed_slider.valueChanged.connect(self.update_speed_limit)

        self.task_button.clicked.connect(self.start_long_task)

    def add_log(self, message):
        now = datetime.now().strftime("%H:%M:%S")
        self.log_list.addItem(f"[{now}] {message}")
        self.log_list.scrollToBottom()

    def publish_velocity(self):
        if self.ros_node is None:
            self.add_log("ROS node is not ready")
            return

        try:
            linear_x = float(self.linear_input.text())
            angular_z = float(self.angular_input.text())

            speed_limit = self.speed_slider.value() / 100.0

            linear_x = linear_x * speed_limit
            angular_z = angular_z * speed_limit

            self.ros_node.publish_cmd_vel(linear_x, angular_z)

            self.status_label.setText(
                f"Robot Status: cmd_vel linear={linear_x:.2f}, angular={angular_z:.2f}"
            )
            self.add_log(f"Published /cmd_vel linear={linear_x:.2f}, angular={angular_z:.2f}")

        except ValueError:
            self.status_label.setText("Robot Status: Invalid velocity value")
            self.add_log("Invalid velocity value")

    def stop_robot(self):
        if self.ros_node is not None:
            self.ros_node.publish_cmd_vel(0.0, 0.0)

        self.status_label.setText("Robot Status: STOPPED")
        self.add_log("STOP command published")

    def change_mode(self, mode):
        if self.ros_node is not None:
            self.ros_node.publish_mode(mode)

        self.add_log(f"Mode changed: {mode}")

    def update_options(self):
        options = []

        if self.obstacle_checkbox.isChecked():
            options.append("Obstacle Avoidance")

        if self.logging_checkbox.isChecked():
            options.append("Sensor Logging")

        if self.camera_checkbox.isChecked():
            options.append("Camera Streaming")

        if options:
            options_text = ", ".join(options)
        else:
            options_text = "None"

        if self.ros_node is not None:
            self.ros_node.publish_options(options_text)

        self.add_log(f"Options updated: {options_text}")

    def update_speed_limit(self, value):
        self.add_log(f"Speed limit changed: {value} %")

    def update_robot_status(self, status):
        self.status_label.setText(f"Robot Status: {status}")
        self.add_log(f"Received status: {status}")

    def update_battery(self, battery):
        if battery < 0:
            battery = 0

        if battery > 100:
            battery = 100

        self.battery_label.setText(f"Battery: {battery} %")
        self.battery_bar.setValue(battery)

    def update_position_demo(self):
        now = datetime.now().strftime("%H:%M:%S")
        self.time_label.setText(f"Time: {now}")

        self.x += 0.05
        self.y += 0.03
        self.yaw += 0.5

        self.position_table.setItem(0, 1, QTableWidgetItem(f"{self.x:.2f}"))
        self.position_table.setItem(1, 1, QTableWidgetItem(f"{self.y:.2f}"))
        self.position_table.setItem(2, 1, QTableWidgetItem(f"{self.yaw:.2f}"))

    def start_long_task(self):
        self.task_button.setEnabled(False)

        self.worker = LongTaskWorker()
        self.worker.progress_signal.connect(self.task_progress_bar.setValue)
        self.worker.status_signal.connect(self.task_status_label.setText)
        self.worker.status_signal.connect(self.add_log)
        self.worker.finished.connect(self.long_task_finished)
        self.worker.start()

    def long_task_finished(self):
        self.task_button.setEnabled(True)
        self.add_log("Long task finished")


def main():
    rclpy.init()

    app = QApplication(sys.argv)

    gui = RobotControlGUI()
    ros_node = RobotRosNode(gui)
    gui.set_ros_node(ros_node)

    ros_timer = QTimer()
    ros_timer.timeout.connect(lambda: rclpy.spin_once(ros_node, timeout_sec=0))
    ros_timer.start(10)

    gui.show()

    exit_code = app.exec_()

    ros_node.destroy_node()
    rclpy.shutdown()

    sys.exit(exit_code)


if __name__ == "__main__":
    main()

실행합니다.

source /opt/ros/humble/setup.bash
python3 15_final_robot_control_gui.py

다른 터미널에서 /cmd_vel을 확인합니다.

source /opt/ros/humble/setup.bash
ros2 topic echo /cmd_vel

GUI에서 속도를 입력하고 Publish /cmd_vel 버튼을 누르면 /cmd_vel이 발행됩니다.

예를 들어 다음처럼 입력합니다.

Linear speed: 0.5
Angular speed: 0.0
Speed limit: 50%

실제 발행되는 값은 다음과 같습니다.

linear.x = 0.25
angular.z = 0.0

속도 제한 슬라이더가 50%이기 때문입니다.

이제 /robot_status 토픽을 발행해 보겠습니다.

ros2 topic pub /robot_status std_msgs/msg/String "{data: 'AUTO MODE RUNNING'}"

GUI의 상태 라벨과 로그창이 업데이트됩니다.

배터리 값도 테스트할 수 있습니다.

ros2 topic pub /battery_percent std_msgs/msg/Int32 "{data: 75}"

GUI의 배터리 프로그레스바가 75%로 변경됩니다.

모드 선택을 바꾸면 /robot_mode 토픽이 발행됩니다.

ros2 topic echo /robot_mode

옵션 체크박스를 변경하면 /robot_options 토픽이 발행됩니다.

ros2 topic echo /robot_options

17. 전체 구조 정리

이번 실습에서는 PyQt와 ROS2를 이용해서 로봇 제어 GUI를 단계적으로 만들어 보았습니다.

최종 구조를 정리하면 다음과 같습니다.

PyQt와 ROS2를 함께 사용할 때 핵심은 이벤트 루프 처리입니다.

PyQt는 다음 이벤트 루프를 사용합니다.

app.exec_()

ROS2는 일반적으로 다음 이벤트 루프를 사용합니다.

rclpy.spin(node)

하지만 둘을 동시에 그대로 사용하면 한쪽이 다른 쪽을 막을 수 있습니다.

그래서 이번 예제에서는 QTimer를 사용했습니다.

ros_timer = QTimer()
ros_timer.timeout.connect(lambda: rclpy.spin_once(ros_node, timeout_sec=0))
ros_timer.start(10)

이 방식은 PyQt GUI를 멈추지 않으면서 ROS2 메시지도 처리할 수 있는 실용적인 구조입니다.

또한 시간이 오래 걸리는 작업은 QThread로 분리했습니다.

class LongTaskWorker(QThread):

GUI 메인 스레드에서 무거운 작업을 직접 실행하면 화면이 멈춥니다.
따라서 실제 로봇 제어 GUI에서는 다음 작업을 별도 스레드로 분리하는 것이 좋습니다.

카메라 영상 처리
AI 추론
지도 저장
로그 파일 처리
긴 ROS2 서비스 호출
네트워크 통신

Leave a Comment