DDSM400 모터 + RPLidar(S2/S3 계열) 기반 차동 구동 모바일 로봇. IMU 는 선택.
Cartographer SLAM으로 지도 생성 → A* 경로 계획 → Tube-MPC 제어기로 자율 주행.
📖 먼저 읽을 것:
docs/DEBUG_LOG_<가장 최신 날짜>.md현재 상태·미결 항목·다음 할 일이 거기 0절에 있습니다. 이 README 는 절차서이고, 무엇이 지금 막혀 있는지는 docs 가 정본입니다. (docs/wiki/index.md가 전체 지도)⏳ 2026-09-22 기준 젯슨에 빌드가 안 돼 있습니다. 실기 작업은 거기서부터 시작합니다.
IMU 는 선택 사항입니다. 2D SLAM 에서 yaw 를 잡는 주체는 IMU 가 아니라 라이다 scan matching 이므로, IMU/EKF 없이 동작합니다.
기본 ( use_imu:=false)예전 구성 ( use_imu:=true)/odom발행드라이버 ekf_filter_node odom→base_linkTF드라이버 ekf_filter_node 드라이버 원본 토픽 /odom/odom_rawIMU 노드 안 띄움 띄움 아래 문서 곳곳의
/odom_raw와 "EKF 가 TF 를 낸다" 는use_imu:=true기준입니다. 기본 구성의 최신 설명은docs/DEBUG_LOG_2026-09-22.md에 있습니다.(예전에 따로 있던
README_ROBOT.md는 2026-09-22 에 이 문서로 합쳤습니다.)매핑 중에는 각속도 ≤ 0.5 rad/s, 선속도 ≤ 0.3 m/s 를 지킵니다 (IMU 가 없으면 스캔 내 모션 왜곡을 보정할 수단이 없습니다). teleop 기본값은 그 2배라 따로 낮춰야 합니다:
-p speed:=0.15 -p turn:=0.4.안 쓰는 파일은
old_file/로 옮겼습니다 (old_file/README.md).
지원 ROS 버전: ROS 2 Jazzy (Ubuntu 24.04) / Humble (Ubuntu 22.04) / Foxy (Ubuntu 20.04)
| 항목 | Foxy (20.04) | Humble (22.04) | Jazzy (24.04) |
|---|---|---|---|
colcon build (5개 패키지 전부) |
✅ 빌드 확인됨 | ✅ | ✅ |
| 실기 파이프라인 (모터·IMU·LiDAR·EKF·Cartographer·MPC) | ✅ | ✅ | ✅ |
gazebo.launch.py 시뮬레이션 |
✅ | ✅ | ❌ 불가 |
| 시스템 Python | 3.8 | 3.10 | 3.12 |
Jazzy에서 Gazebo 시뮬레이션이 안 되는 이유: urdf/relayrobot.xacro·relayrobot.gazebo가
Gazebo Classic 플러그인(libgazebo_ros_diff_drive.so, libgazebo_ros_ray_sensor.so,
libgazebo_ros_joint_state_publisher.so)을 쓰는데, Gazebo Classic은 2025년 1월 EOL이라
ros-jazzy-gazebo-ros 패키지가 없습니다. Jazzy에서 시뮬레이션을 돌리려면 ros_gz_sim +
gz_ros2_control 기반으로 URDF를 다시 써야 합니다. 실기 주행은 Jazzy에서도 정상 동작합니다
(시뮬레이션 런치만 못 씀).
코드 자체는 배포판 중립입니다. rclpy/launch API를 배포판별로 갈리는 방식으로 쓰지 않고,
np.float·np.int 같은 NumPy 2.0에서 제거된 별칭도 없어서 20.04~24.04 어디서든 그대로 빌드됩니다.
바뀌는 건 아래 apt 명령의 배포판 이름뿐입니다.
위 표는 "빌드가 되는 배포판" 이고, 아래가 실제로 쓰는 조합입니다.
| 로봇 (젯슨 Orin Nano) | 개발 PC | |
|---|---|---|
| OS | Ubuntu 22.04 (JetPack 6.2.1 / L4T 36.x) | Ubuntu 24.04 |
| ROS | Humble | Jazzy |
| 역할 | 드라이버·SLAM·A*·MPC — 제어 루프 전부 | RViz·rqt·진단 도구 |
젯슨은 Jazzy 가 아니라 Humble 입니다. JetPack 6.x 는 Ubuntu 24.04 가 아니라 22.04 입니다.
ros-jazzy-*를 젯슨에 설치하려 하면 패키지를 못 찾습니다. 근거:docs/DEBUG_LOG_2026-09-03.md4절,docs/JETSON_SETUP.md.PC(Jazzy) ↔ 젯슨(Humble) 은 표준 메시지만 쓰므로 cross-distro 로 토픽이 그대로 오갑니다 (
ROS_DOMAIN_ID=0). 근거:docs/DEBUG_LOG_2026-09-07.md.numpy 는 2.2.6 이고 내리면 안 됩니다. tube MPC 스택이 요구합니다. ROS 라이브러리가 numpy 2 에서 깨지면 그 라이브러리를 올려서 풉니다 (
transforms3d>=0.4.2). 근거:docs/DEBUG_LOG_2026-09-06.md3절.
graph TD
subgraph "Sensing & Actuation (Hardware)"
Motor[DDSM400 Motors]
Lidar[RPLidar S3]
IMU[EB-IMU Sensor]
end
subgraph "Position Estimation (Localization)"
Driver[Real Robot Driver]
EKF[EKF Filter Node]
SLAM[Cartographer SLAM]
Motor -->|Encoders| Driver
Driver -->|"/odom (기본)"| SLAM
Driver -->|"/odom_raw (use_imu)"| EKF
IMU -->|"/ebimu_data (선택)"| EKF
Lidar -->|"/scan"| SLAM
EKF -->|"/odom (use_imu)"| SLAM
SLAM -.->|"TF: map -> odom"| Global_Tree((TF Tree))
end
subgraph "Navigation & Control"
GUI[Custom Control GUI]
MPC[Tube MPC Planner]
Driver -->|"위치는 TF 로 전달"| MPC
Global_Tree -.->|"TF Correction"| MPC
MPC -->|"/cmd_vel"| Driver
Driver -->|"Serial (10ms Latency)"| Motor
end
Phase 0 ✅ 하드웨어 검증 Motor / IMU / LiDAR 단독 테스트 + GUI
Phase 1 ▶ Odometry 정밀 검증 EKF 융합 odom 정확도 확인 → SLAM 입력 기준 확보
Phase 2 Cartographer SLAM 지도 생성 + map→odom TF 안정성 확인
Phase 3 🧪 경로 계획 검증 A* 경로 계획기 + /global_path 토픽 시각화
Phase 4 🧪 Tube-MPC 통합 짧은 구간 추종 → 파라미터 튜닝
Phase 5 완전 자율주행 전체 파이프라인 통합 + 성능 검증
🧪 = 코드 완성 + 시뮬레이션(STAGE 5.5) 검증 완료. 실기 검증만 남음 (2026-09-13)
막혀 있는 곳은 코드가 아니라 하드웨어다. Phase 3·4 의 코드(A*, Tube-MPC)는 이미 다 있고
tools/mpc_sim.py리허설로 끝까지 주행까지 확인했다. 순서상 Phase 1(odom 캘리브)과 Phase 2(SLAM)를 실기로 통과해야 그 위에 얹을 수 있을 뿐이다. Phase 2(SLAM)는 새로 작성할 코드가 없다 —cartographer.launch.py로 바로 간다.
| 항목 | 확인 내용 | 완료 기준 |
|---|---|---|
| RPM 팩터 | /600.0 변환 실측 |
1m 직진 → x ≈ 0.9~1.1 |
| wheel_base | 0.165m 실측 대조 |
90° 회전 → yaw ≈ 1.4~1.7 rad |
| EKF 융합 | IMU 보정 효과 | 직진 후 yaw drift 없음 |
| TF tree | odom→base_link 발행 |
tf2_echo 값 출력 |
- 입력:
/odom(EKF 융합) +/scan+ TF tree - 핵심 검증:
map→odomTF 발행 여부, 지도 닫힘(loop closure) 확인 - 산출물:
~/robot_map.yaml+~/robot_map.pgm저장 - 이슈 예상:
my_cartographer.lua튜닝 (특히 실내 공간 크기에 맞는map_resolution)
SLAM에서 odom은 "정답"이 아니라 scan 매칭의 "초기 추측값(prior)" 입니다. Cartographer는 odom으로 "대충 이만큼 움직였겠다"를 예측하고, 그 근처에서 scan을 맵에 맞춰 미세조정합니다. 그래서 odom이 전역적으로 정확할 필요는 없지만 국소적으로 매끄럽고(점프 없이) + 스케일이 맞고 + yaw가 안정적이어야 합니다.
불안정 유형별로 scan이 잡아주는 정도가 다릅니다:
| odom 불안정 유형 | scan이 보정하나? |
|---|---|
| 느린 드리프트 / 스케일 오차 (1m인데 0.9m) | ✅ 매 프레임 맵에 맞춰 보정 + loop closure가 누적분 사후 교정 |
| 고주파 노이즈 / 순간 점프 (jitter, 튐) | |
| yaw(회전) 오차 | ❌ 가장 치명적 — 위치 오차보다 scan 매칭을 훨씬 크게 망침 |
보정 장치 (my_cartographer.lua):
use_online_correlative_scan_matching = true→ Ceres 정밀매칭 전에 넓은 범위를 brute-force 탐색 → odom 추측이 좀 틀려도 복구 (불안정 odom의 1차 방어선, CPU 더 씀)- Ceres scan matcher → odom 추측 ↔ scan을 가중치로 절충
- Global SLAM (pose graph + loop closure) → 누적 드리프트를 사후 교정
꼭 기억할 두 가지:
- 이 설정은
use_imu_data = false라 Cartographer가 IMU를 직접 안 씁니다. IMU는 오직 EKF →/odom경로로만 들어가요. 즉 (바퀴+IMU yaw 융합) odom 품질 = SLAM prior 품질 그 자체. odom이 불안정하면 그대로 SLAM 입력으로 갑니다. - 특징 빈약 환경(긴 직선 복도, 텅 빈 큰 방, 유리벽, LiDAR
max_range8m 밖)에서는 scan이 위치를 못 고정해 odom에 거의 전적으로 의존 → 이때 odom 불안정이 그대로 드러남.
튜닝 포인트:
- SLAM이 odom 때문에 흔들리면 →
TRAJECTORY_BUILDER_2D.ceres_scan_matcher의translation_weight/rotation_weight를 낮춰 scan을 더 신뢰. - 특징 없는 복도에서 미끄러지면 → 위 weight를 올려 odom을 더 신뢰.
한 줄 요약: 느린 드리프트는 scan이 잡아주지만 odom의 점프·yaw 오차는 못 잡는다. 그래서 Phase 1에서 odom을 매끄럽게(RPM 팩터·wheel_base 캘리브) 만드는 게 SLAM 안정성의 전제다.
- 입력:
/map+ 목표 좌표(/mpc_goal) - 핵심 검증:
/global_path에 경로 토픽 발행 확인, 원격 PC RViz에서 경로 시각화 - 이슈 예상: 지도 해상도와 A* 격자 크기 불일치 →
path_planner.py파라미터 조정
- 1차: 1~2m 직선 경로 추종 (짧게 먼저)
- 2차: 곡선 경로 추종 + 파라미터 튜닝 (
horizon,velocity_limit,omega_limit) - 이슈 예상: QP 풀이 발산 →
horizon:=4,velocity_limit:=0.1로 줄여서 시작
- 전체 런치 단일 파일화
- 장애물 재계획 확인
- 비상 정지(
/cmd_velzeroing) 안전 기능
Phase 0~5가 "한 대가 지도를 만들고 목표점까지 스스로 간다"까지라면, 그 다음은 반복 운용이 가능한 서비스 로봇으로 확장하는 단계입니다.
| 방향 | 내용 | 왜 필요한가 |
|---|---|---|
| 지도 재사용 (Localization 모드) | 매번 SLAM으로 새 지도를 만들지 않고, 저장된 robot_map + Cartographer pure localization(또는 AMCL)으로 기존 지도에서 위치만 추정 |
운용 단계에서는 지도 생성이 아니라 "아는 공간에서 반복 주행"이 기본. SLAM 상시 구동은 CPU 낭비 + 지도 오염 위험 |
| Nav2 스택 병행 평가 | 자작 A*+Tube-MPC와 Nav2(planner/controller server)를 같은 코스에서 비교. 자작 MPC는 Nav2 controller 플러그인으로 이식 가능 | 자작 스택은 학습·튜닝 자유도가 높지만, 복구 행동(recovery)·코스트맵 갱신·라이프사이클 관리는 Nav2가 이미 검증됨 |
| 동적 장애물 대응 | 현재는 정적 지도 기반 A* 재계획 수준 → local costmap + 속도 장애물(사람) 감속/정지 계층 추가 | 실환경 투입의 최소 안전 요건. Tube-MPC의 tube 제약과 자연스럽게 결합 가능 |
| 멀티센서 융합 강화 | 뎁스 카메라 추가(라이다 평면 밖 장애물 — 낮은 턱, 테이블 상판), EKF에 시각 오도메트리 보조 | 2D LiDAR 단독의 구조적 사각(높이 정보 없음) 보완 |
| 매니퓰레이터 연계 (모바일 매니퓰레이션) | Relay Robot이라는 이름대로, 정지 스테이션의 로봇 팔(예: Trossen arm 셀)과 도킹 → 부품/트레이 릴레이 운반 | 이동(이 프로젝트) + 조작(multi_robot_api)을 잇는 최종 목표 시나리오 |
| 운용 안정화 | 단일 launch + systemd 자동 기동, 배터리/통신 감시, /cmd_vel watchdog(통신 두절 시 자동 정지) |
데모가 아니라 "켜두면 도는" 로봇의 조건 |
진행 원칙은 로드맵과 동일: 한 번에 하나씩, 하위 계층 검증 없이 상위 계층으로 넘어가지 않습니다. 예: 지도 재사용(localization)이 안정되기 전에 동적 장애물 대응을 얹지 않기.
2026-09-22 정리. 안 쓰는 파일은 전부
old_file/로 옮겼습니다(지운 게 아닙니다). 무엇을 왜 옮겼는지는old_file/README.md. 아래가 지금 실제로 도는 전부입니다.
1. real_robot_driver_260519.py 모터 + 바퀴 오도메트리 + TF <- 여기부터
2. motor_drive_1.py 시리얼 프로토콜 (부호·단위가 여기서 흡수됨)
3. my_cartographer.lua SLAM 파라미터
4. path_planner.py A* — 지도 팽창 + 격자 탐색
5. bridge_node.py Tube-MPC — 참조 궤적 + QP
6. TubeMPCPlanner.py MPC 수학 (제약 집합 / tube)
src/
├── relayrobot_description/ ★ 로봇 본체 패키지
│ ├── relayrobot_description/
│ │ ├── real_robot_driver_260519.py ★ 모터 + 휠 odom + TF (use_imu 로 동작 바뀜)
│ │ ├── motor_drive_1.py ★ DDSM 시리얼. 부호(DIR_L/DIR_R)·단위를 흡수
│ │ ├── odom_calibrate.py 기구학 보정 도구 (직진/회전)
│ │ └── odom_listener.py /odom 을 터미널에 찍는 최소 예제
│ ├── launch/
│ │ ├── real_robot_260519.launch.py ★ 메인. 모터+LiDAR (+IMU/EKF 는 use_imu:=true)
│ │ └── cartographer.launch.py ★ SLAM
│ ├── config/
│ │ ├── my_cartographer.lua ★ SLAM 파라미터 (use_imu_data=false)
│ │ ├── ekf.yaml use_imu:=true 일 때만 쓰임
│ │ └── nav.rviz 자율주행 관측용 RViz 프리셋 (PC 에서 띄움)
│ └── urdf/relayrobot.xacro ★ 링크·조인트. lidar_v1_1 위치가 SLAM 에 직결
│
├── mpc_tubempc_bridge/ ★ 자율주행 (SLAM -> A* -> MPC)
│ └── src/mpc_tubempc_bridge/
│ ├── path_planner.py ★ A* + 장애물 팽창 (/map -> /global_path)
│ └── bridge_node.py ★ Tube-MPC. TF(map->base_link)로 위치를 받는다
│
├── ddsm_example/
│ ├── mpc_tubempc/TubeMPCPlanner.py ★ MPC 수학. bridge_node 가 sys.path 로 가져온다
│ │ (경로가 코드에 박혀 있어 옮기면 안 됨)
│ └── ddsm_example/*.ino, json_cmd.h 보드 펌웨어 소스.
│ 프로토콜이 의심되면 문서 말고 이걸 본다
├── ebimu_pkg/ebimu_pkg/ebimu_publisher.py IMU 드라이버. use_imu:=true 일 때만
├── gui_py/gui_py/hardware_test.py ★ 원격 조종 GUI (/cmd_vel 발행). ssh -X 로 띄움
└── sllidar_ros2/ 서드파티. 우리는 sllidar_s2_launch.py 만 씀
scripts/robot-up.sh ★ tmux 상시 기동 (sensors|full|slam|nav)
scripts/robot-off.sh / .ps1 안전 종료 (SD카드 보호)
tools/odom_check.py PC 에서 원격 오도메트리 진단
tools/mpc_sim.py 로봇 없이 A*+MPC 리허설
docs/DEBUG_LOG_<날짜>.md ★ 세션 기록. 최신 것이 정본
old_file/ 보관소. COLCON_IGNORE 라 빌드 영향 없음
★ = 실주행 체인에 직접 관여. 나머지는 도구/보조.
워크스페이스 패키지는 5개입니다 (2026-09-22 에
relayrobot_driver를 폐지하고odom_sub를odom_listener로 합쳤습니다).
| 장치 | 심볼릭 링크 | 프로토콜 |
|---|---|---|
| DDSM HAT(B) 모터 컨트롤러 | /dev/motor → ttyACM0 |
USB-CDC, 115200 bps |
| EBIMU9DOFV5 IMU | /dev/ttyimu → ttyUSB0 |
UART, 115200 bps |
| RPLidar S2/S3 계열 | /dev/rplidar → ttyUSB1 |
UART, 1,000,000 bps |
A1/A2 가 아닙니다.
sllidar_a1_launch.py(115200)로 띄우면SL_RESULT_OPERATION_TIMEOUT으로 죽습니다. 단독 실행은sllidar_s2_launch.py serial_port:=/dev/rplidar. (2026-09-06 실측 정정)
위 매핑은 udev 규칙으로 자동 고정됩니다. 설정 방법은 아래 "USB 포트 고정" 섹션을 참고하세요.
⚠️ 처음 라이다·IMU·모터를 꽂았다면, 링크 이름만 믿지 마세요. 예전 규칙이 남아 있으면/dev/motor링크가 실제로는 IMU 포트를 가리키는 사고가 실제로 있었습니다(2026-07-09). 이 경우 모터 드라이버가 IMU 텍스트를 JSON으로 파싱하려다 계속 실패 → rpm 피드백이 늘 0, "토크 없음/EIO"처럼 보입니다. 처음 연결 시에는 아래 Step 1의 raw-read 검증으로 각 포트의 정체를 실제로 확인한 뒤 규칙을 적용하세요.
[하드웨어] [드라이버 노드] [토픽]
/dev/motor ──► real_robot_driver_260519 ──► /odom (기본)
──► TF: odom → base_link (기본)
──► /joint_states
sub: /cmd_vel ◄──────────────────────────
/dev/ttyimu ──► ebimu_publisher ──► /ebimu_data (sensor_msgs/Imu)
/dev/rplidar ──► sllidar_node ──► /scan (sensor_msgs/LaserScan)
[센서 융합] ※ use_imu:=true 일 때만. 이때 드라이버는 /odom_raw 만 내고 TF 는 양보한다.
/odom_raw ──┐
├──► ekf_filter_node ──► /odom (nav_msgs/Odometry)
/ebimu_data ┘ ──► TF: odom → base_link
★ odom→base_link TF 발행자는 시스템에 하나뿐이어야 한다. use_imu 가 그걸 정한다.
[SLAM]
/scan + TF ──► cartographer_node ──► /map
──► TF: map → odom
[경로 계획 · 제어] ※ 위치는 토픽이 아니라 TF(map→base_link)로 받는다
/map + /mpc_goal + TF ──► path_planner ──► /global_path
──► /inflated_map
/global_path + TF ──► bridge_node ──► /cmd_vel
──► /mpc/reference_path, /mpc/tracking_error, /mpc/status
[TF 트리]
map ──[cartographer]──► odom ──[드라이버 또는 ekf_node]──► base_link
└─[robot_state_publisher]──► lidar_v1_1
| 토픽 | 타입 | 발행 노드 |
|---|---|---|
/odom |
nav_msgs/Odometry |
real_robot_driver_260519 (기본) |
/odom_raw |
nav_msgs/Odometry |
real_robot_driver_260519 (use_imu:=true) |
/odom |
nav_msgs/Odometry |
ekf_filter_node (use_imu:=true) |
/ebimu_data |
sensor_msgs/Imu |
ebimu_publisher (use_imu:=true) |
/scan |
sensor_msgs/LaserScan |
sllidar_node |
/map |
nav_msgs/OccupancyGrid |
cartographer_node |
/cmd_vel |
geometry_msgs/Twist |
bridge_node |
/global_path |
nav_msgs/Path |
path_planner |
/inflated_map |
nav_msgs/OccupancyGrid |
path_planner (로봇 반경 팽창 결과) |
/mpc/reference_path |
nav_msgs/Path |
bridge_node (지금 호라이즌) |
/mpc/tracking_error |
geometry_msgs/Vector3 |
bridge_node (e_act x, y, θ) |
/mpc/status |
std_msgs/String |
bridge_node (RUNNING/QP_FAILED/GOAL_REACHED/NO_POSE/NO_PATH) |
왜 A*/MPC 는
/odom을 안 쓰고 TF 를 쓰나:/odom은 odom 프레임인데 지도·목표·경로는 map 프레임이다. 토픽을 그대로 빼면 SLAM 이 드리프트를 보정한 만큼(map→odom)이 그대로 추종 오차로 둔갑한다. 그래서 두 노드 모두global_frame(기본map) →base_linkTF 로 현재 위치를 받는다. SLAM 없이 시험할 때는-p global_frame:=odom으로 내리면 그대로 돈다.
Jazzy (Ubuntu 24.04):
sudo apt update && sudo apt install -y \
ros-$ROS_DISTRO-cartographer-ros \
ros-$ROS_DISTRO-tf-transformations \
ros-$ROS_DISTRO-teleop-twist-keyboard \
ros-$ROS_DISTRO-nav2-map-server \
ros-$ROS_DISTRO-robot-localization # use_imu:=true (EKF) 를 쓸 때만 필요Humble (Ubuntu 22.04):
sudo apt update && sudo apt install -y \
ros-humble-robot-localization \
ros-humble-cartographer-ros \
ros-humble-tf-transformations \
ros-humble-teleop-twist-keyboard \
ros-humble-nav2-map-serverFoxy (Ubuntu 20.04):
sudo apt update && sudo apt install -y \
ros-foxy-robot-localization \
ros-foxy-cartographer-ros \
ros-foxy-tf-transformations \
ros-foxy-teleop-twist-keyboard \
ros-foxy-nav2-map-serverPython 의존 패키지:
주의: ROS 2 Jazzy는 시스템 Python 3.12(
/usr/bin/python3)에 묶여 있습니다. conda 환경(3.11/3.13 등)에서는rclpyimport가 실패하므로, 반드시 conda를 빠져나온 뒤 시스템 파이썬에 설치하세요. (conda config --set auto_activate_base false권장)
conda deactivate # conda 밖(시스템 3.12)으로
# Ubuntu 24.04는 시스템 pip이 보호돼 있어 --break-system-packages 필요
pip3 install --break-system-packages --user \
numpy scipy cvxpy polytope osqp cvxopt transforms3d pyserialUbuntu 22.04/20.04는 시스템 pip이 보호돼 있지 않으므로
--break-system-packages없이 설치하세요.numpy·scipy·pyserial은rosdep install --from-paths src --ignore-src -r -y로도 설치됩니다 (각package.xml에 선언돼 있음). apt/rosdep으로 안 잡히는 건cvxpy·polytope·osqp·cvxopt뿐입니다.
매번 source /opt/ros/jazzy/setup.bash를 치는 대신, alias 하나로 해결합니다. 최초 1회만 실행하면 이후 모든 터미널에서 사용 가능합니다.
Jazzy 사용자:
echo 'alias ros_setup="source /opt/ros/jazzy/setup.bash && source ~/mobile_robot_proto_type/install/setup.bash && echo ROS2 환경 로드 완료"' >> ~/.bashrc
source ~/.bashrcHumble 사용자:
echo 'alias ros_setup="source /opt/ros/humble/setup.bash && source ~/mobile_robot_proto_type/install/setup.bash && echo ROS2 환경 로드 완료"' >> ~/.bashrc
source ~/.bashrc이후 새 터미널을 열 때마다:
ros_setup매번 자동 소싱이 필요하면 alias 대신
echo 'source /opt/ros/jazzy/setup.bash' >> ~/.bashrc로 직접 등록하세요.
단, ROS가 없는 환경에서 터미널을 열면 오류가 출력됩니다.
Linux는 USB 장치를 꽂는 순서대로 /dev/ttyUSB0, /dev/ttyUSB1 번호를 붙이기 때문에, 재부팅하면 라이다와 IMU의 포트 번호가 뒤바뀔 수 있습니다. udev 규칙을 설정하면 재부팅·재연결 후에도 장치 이름이 항상 고정됩니다.
3개(라이다·IMU·모터)를 모두 꽂은 직후, 다른 작업 전에 이 스크립트 하나로 연결 상태를 먼저 확인하세요.
udev 심볼릭 링크(/dev/rplidar·/dev/ttyimu·/dev/motor)를 확인하고, 링크가 없으면 실제 USB 칩(vendor/product)으로 각 장치를 찾아 어느 포트에 붙었는지까지 알려줍니다.
~/mobile_robot_proto_type/check_devices.sh정상(규칙 적용 완료) 출력:
✅ 라이다 : 연결됨 (/dev/rplidar → /dev/ttyUSB1)
✅ IMU : 연결됨 (/dev/ttyimu → /dev/ttyUSB0)
✅ 모터드라이버: 연결됨 (/dev/motor → /dev/ttyACM0)
결과: 3개 기기 모두 연결 확인됨 ✅
✅= 심볼릭 링크까지 정상 → 바로 실행 가능.⚠️= 장치는 꽂혀 있으나 링크가 없음 → 아직 udev 규칙 미적용. Step 2로 규칙을 적용하세요.❌= 장치 자체가 안 잡힘 → USB 재연결/전원/케이블 확인.
스크립트는 정본 규칙(
99-robot-devices.rules)과 같은 칩 매핑(모터1a86:55d3/ 라이다·IMU10c4:ea60+ product 문자열)을 씁니다. 규칙 적용 후(Step 2) 다시 한 번 돌려서 세 줄 모두✅인지 확인하는 것이 마지막 관문입니다.
라이다, IMU, 모터 컨트롤러를 모두 연결한 뒤 아래 명령으로 어떤 포트에 어떤 장치가 연결됐는지 확인합니다.
for dev in /dev/ttyUSB* /dev/ttyACM*; do
echo "=== $dev ===" && udevadm info -a -n "$dev" | grep -E 'ATTRS\{product\}|ATTRS\{serial\}' | head -2
done출력 예시:
=== /dev/ttyUSB0 ===
ATTRS{product}=="CP2102 USB to UART Bridge Controller" ← EB-IMU
=== /dev/ttyUSB1 ===
ATTRS{product}=="CP2102N USB to UART Bridge Controller" ← RPLidar S3
=== /dev/ttyACM0 ===
ATTRS{product}=="USB Single Serial" ← 모터 컨트롤러
| 칩 | 장치 |
|---|---|
CP2102 (구형) |
EB-IMU |
CP2102N (신형) |
RPLidar S3 |
USB Single Serial (QinHeng) |
모터 컨트롤러 → 항상 ttyACM0 |
포트 번호(USB0/USB1)가 뒤바뀌어도 상관없습니다. 칩 종류로 장치를 식별하므로 꽂는 순서는 무관합니다.
장치 교체 시: 규칙은 시리얼 번호가 아니라 칩 종류(vendor/product)로 식별하므로, 같은 칩을 쓰는 모델로 교체하면 규칙 파일을 안 고쳐도 그대로 잡힙니다.
- 모터: QinHeng 칩(
1a86:55d3)은 시스템에서 유일 → 벤더/제품 ID만으로 식별- 라이다: IMU와 벤더/제품 ID(
10c4:ea60)가 같아 product 문자열(CP2102N)로 구분단, 라이다/IMU를 다른 칩을 쓰는 장치로 바꾸면 규칙의 product 문자열을 새 값으로 수정해야 합니다. (
udevadm info -a -n /dev/ttyUSBx | grep product로 확인)
왜 필요한가: 칩(vendor/product)만으로 식별하면 대부분 맞지만, IMU와 라이다는 같은 CP210x라 product 문자열이 어긋나거나 예전 규칙이 남아 있으면 링크가 엉뚱한 장치를 가리킬 수 있습니다. 실제로 2026-07-09에
/dev/motor가 IMU 포트를 가리켜 모터가 "토크 없음"처럼 보인 사고가 있었습니다. 처음 하드웨어를 꽂았을 때 한 번, 각 포트에서 raw 바이트를 읽어 정체를 눈으로 확인하세요.
python3 - <<'PY'
import serial
for p in ['/dev/ttyUSB0', '/dev/ttyUSB1', '/dev/ttyACM0']:
try:
s = serial.Serial(p, 115200, timeout=1.0)
data = s.read(120); s.close()
print(f"{p}: {data[:80]!r}")
except Exception as e:
print(f"{p}: (열기 실패) {e}")
PY포트별 시그니처 (이걸로 정체 확정):
| 장치 | raw 읽기 결과 | 근거 |
|---|---|---|
| IMU (CP210x) | *ROLL,PITCH,YAW,... 형태의 텍스트를 계속 흘림 |
EBIMU가 상시 스트리밍 |
| 모터 (USB Single Serial, ttyACM0) | 평소 무응답 (DDSM 명령을 보내야 hex 프레임 \x01\xa0... 로 응답) |
명령 없으면 침묵 |
| 라이다 (CP210x) | 바이너리 (비-ASCII 바이트 덩어리, 실제 통신은 1000000 bps) | 스캔 프레임 |
모터가 진짜 응답하는지까지 확인하려면, 저장소 루트의
old_file/ddsm_raw_monitor.py로 속도 명령을 보내며 raw 응답(spd/cur변화)을 봅니다:python3 old_file/ddsm_raw_monitor.py. (launch가/dev/motor를 점유 중이면 먼저Ctrl-C로 끄고 실행)
권한도 함께 확인: udev 규칙에
MODE="0666"이 있어야 하고, 사용자가dialout그룹에 속해야 포트를 열 수 있습니다.groups | grep dialout로 확인, 없으면sudo usermod -aG dialout $USER후 재로그인.
아래 명령을 그대로 실행하세요. (setup_udev_rules.sh 대신 이 방법을 권장합니다)
sudo cp ~/mobile_robot_proto_type/src/relayrobot_description/scripts/99-robot-devices.rules /etc/udev/rules.d/ \
&& sudo udevadm control --reload-rules \
&& sudo udevadm triggerUSB를 모두 뽑았다가 다시 꽂은 후, Step 0의 check_devices.sh 를 다시 실행해 세 줄 모두 ✅ 인지 확인하세요:
~/mobile_robot_proto_type/check_devices.sh링크 대상을 직접 보고 싶으면:
ls -la /dev/rplidar /dev/ttyimu /dev/motor정상 출력:
lrwxrwxrwx ... /dev/motor -> ttyACM0
lrwxrwxrwx ... /dev/rplidar -> ttyUSB1
lrwxrwxrwx ... /dev/ttyimu -> ttyUSB0
cd ~/mobile_robot_proto_type
ros_setup # 또는 source /opt/ros/jazzy/setup.bash
colcon build --symlink-install --packages-ignore aws-robomaker-hospital-world
echo 'source ~/mobile_robot_proto_type/install/setup.bash' >> ~/.bashrc
source ~/.bashrc
--symlink-install필수: bridge_node.py가 소스 경로 기반으로 TubeMPCPlanner를 import합니다.
aws-robomaker-hospital-world는 Gazebo 시뮬레이션 전용이므로 실제 로봇 구동 시 제외합니다.
매번 터미널 여러 개에 STAGE 1~5 명령을 치는 대신, 모터·IMU·LiDAR·EKF 오도메트리를 창 하나에서 켜고 확인할 수 있는 GUI입니다. SSH로 원격 제어/모니터링할 때 특히 편합니다.
ros_setup
ros2 run gui_py hw_test기능:
- 드라이버 노드 Start/Stop: Motor / IMU / LiDAR / EKF 각각 버튼으로 실행·종료.
●표시가 토픽 수신되면 초록, 끊기면 빨강. - Motor: 속도 슬라이더 + 전진/후진/좌·우회전/정지 버튼 →
/cmd_vel발행, odom 실측 v·ω 표시 (구독 토픽은use_ekf파라미터가 정함: 기본/odom,-p use_ekf:=true면/odom_raw) - IMU: 오도메트리 관련 값(yaw, gyro_z, acc_x, acc_y) + Hz. Start 직후 ~10초 캘리브레이션 안내(로봇 정지 유지)
- LiDAR:
/scanHz, 포인트 수, 최소거리, 정면거리 - Odometry (
/odom): 위치x/y/yaw+ 속도v/ω. 기본 구성에서는 Motor Start 만으로/odom이 나옵니다(드라이버가 직접 발행).-p use_ekf:=true로 띄운 경우에만 Motor·IMU → EKF Start 순서가 필요합니다. - 창을 닫거나(X) 터미널을 닫아도(
Ctrl-C/SIGHUP) GUI가 띄운 드라이버 노드를 함께 종료.
/odom확인 순서 (기본): Motor Start → Odometry 패널에 값 표시.-p use_ekf:=true인 경우: Motor → IMU(캘리브 10초) → EKF Start.
⚠️ 기본 구성에서 EKF Start 는 막혀 있습니다. 누르면 드라이버와 EKF 가odom→base_linkTF 를 이중 발행하기 때문입니다. 상태바에 이유가 뜹니다.
아래 STAGE 1~6은 GUI 없이 터미널에서 단계별로 디버깅할 때의 수동 절차입니다.
아래 계층은 아래에서 위로 쌓인다. 하위가 흔들리면 상위는 반드시 무너지므로, 통과 기준을 못 채웠으면 다음 단계로 넘어가지 않는다.
| 단계 | 무엇 | 터미널 | 통과 기준 | 실패하면 |
|---|---|---|---|---|
| STAGE 0 | USB·전원·시계 | PC-1 |
심볼릭 링크 3개 ✅, PC↔젯슨 시계 차 < 1s | 하드웨어 점검 |
| STAGE 1 | 모터 단독 | 젯슨-1 PC-1 |
명령에 바퀴가 응답, "Connected" |
udev / 전원 / type:210 |
| STAGE 2 | IMU 단독 | 젯슨-1 PC-1 |
40~60 Hz 발행 | /dev/ttyimu |
| STAGE 2-B | IMU yaw 방향 | 젯슨-1 PC-1 PC-2 |
왼쪽 회전에 yaw 증가(+) | 부호 뒤집기 (아래) |
| STAGE 3 | LiDAR 단독 | 젯슨-1 PC-1 PC-2 |
/scan 10 Hz |
보드레이트 1000000 |
| STAGE 4 | EKF 융합 odom | 젯슨-1 PC-1 |
1m 직진 → 0.9~1.1 | STAGE 2-B 로 |
| STAGE 4-B | /cmd_vel watchdog |
젯슨-1 PC-1 |
0.5초 뒤 자동 정지 | 자율주행 금지 |
| STAGE 5 | SLAM | 젯슨-1 젯슨-2 PC-1 PC-2 |
map→odom 발행, 지도 닫힘 |
STAGE 4 로 |
| STAGE 5.5 | 리허설 (로봇 없이) | PC-1~PC-5 |
목표 도달, QP 실패 0 | 코드 문제 |
| STAGE 6 | 자율주행 | 젯슨 tmux + PC-1~PC-3 |
목표 도달 | STAGE 5.5 로 |
STAGE 5.5 는 하드웨어가 없어도 지금 할 수 있다. 순서상 여기 있지만 언제든 먼저 돌려도 된다.
이 문서의 모든 명령은 어느 기계의 몇 번째 터미널인지 를 앞에 붙여 둔다.
| 표기 | 어디서 | 여는 법 | 프롬프트 |
|---|---|---|---|
[PC-n] |
내 컴퓨터 | 터미널 새 창/탭 | jk@jk:~$ |
[젯슨-n] |
로봇(젯슨) | ssh robot 후 실행, 또는 robot-up.sh 의 tmux 창 |
frlab@frlab:~$ |
# 모든 터미널에서 공통으로 먼저 실행
conda deactivate # ⚠️ PC 는 conda(3.13)가 ROS 파이썬(3.12)을 가린다. 필수.
ros_setup # ROS + 워크스페이스 소싱젯슨 터미널을 여는 두 가지 방법:
# (A) 명령 하나만 던질 때
ssh robot '<명령>'
# (B) 계속 붙어서 로그를 볼 때 — robot-up.sh 가 띄운 tmux 창
ssh -t robot 'tmux attach -t robot'
# 창 이동: Ctrl-b 다음 숫자 / 빠져나오기: Ctrl-b 누르고 d
# ⚠️ Ctrl-c 로 나가지 말 것 (노드가 죽는다)
⚠️ RViz 와 rqt_plot 은 반드시 PC 에서. 젯슨에서 띄우면 무선으로 화면을 밀어야 해서 로봇이 느려진다. 젯슨은 노드만 돌린다.
⚠️ 지금 내가 PC 인가 젯슨인가 를 프롬프트로 확인하는 습관을 들일 것.robot별칭은 PC 에만 있다 (2026-09-13 에 실제로 헷갈린 적 있음).
# [PC-1] 장치 3개가 다 붙었는지
ssh robot 'lsusb; ls -l /dev/motor /dev/rplidar /dev/ttyimu'
# [PC-1] PC 와 젯슨의 시계 차이 — 1초 이상 벌어지면 안 된다
ssh robot 'date -u +%s.%N'; date -u +%s.%N왜 시계가 중요한가: TF 는 타임스탬프로 보간한다. 두 기계의 시계가 어긋나면
PC RViz 에서 TF extrapolation 에러가 나고 지도가 아예 안 그려진다.
어긋나 있으면 젯슨에 sudo apt install chrony 로 NTP 를 맞춘다.
전원 순서: 전부 끄기 → USB 전부 꽂기 → 모터 보드 12V → 젯슨 전원 마지막.
모터 보드는 전원 사이클마다 {"T":11002,"type":210} 초기화가 1회 필요하다.
통과 기준: 심볼릭 링크 3개 모두 ✅, 시계 차 < 1초
목표: DDSM400 시리얼 통신 확인, 전진/후진/회전 명령에 실제 응답 확인
# [젯슨-1] 모터 + 휠 오도메트리 노드
ros2 run relayrobot_description real_robot_driver_260519
# [PC-1] 전진 명령
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.1}, angular: {z: 0.0}}" --once
# 정지
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.0}}" --once
# [PC-1] 오도메트리 확인
ros2 topic echo /odom --field twist.twist.linear # use_imu:=true 면 /odom_raw성공 기준: /odom의 twist.linear.x ≈ 0.1 (±30%), 로봇 실제 전진
실패 체크리스트:
[ ] ls -la /dev/motor → 없으면 USB 재연결 후 udev 재확인
[ ] ESP32 모드 점퍼 확인 → Arduino 모드면 JSON 무응답
[ ] ros2 run 실행 로그에 "Connected" 출력 확인
기본 구성에서는 건너뜁니다. 2026-09-22 부터 IMU 없이 도는 것이 기본입니다. 2D SLAM 의 yaw 는 라이다 scan matching 이 잡습니다. STAGE 1 → STAGE 3 으로 가세요.
목표: EBIMU 데이터 수신, yaw 방향 정합성 확인
주의: 노드 시작 후 10초간 캘리브레이션이 진행됩니다. 이 시간 동안 로봇을 움직이지 마세요.
# [젯슨-1] IMU 노드
ros2 run ebimu_pkg ebimu_publisher \
--ros-args -p port:=/dev/ttyimu -p frame_id:=base_link
# [PC-1] 데이터 확인
ros2 topic hz /ebimu_data # 목표: 40~60 Hz
ros2 topic echo /ebimu_data --field orientation성공 기준: 40~60 Hz 발행, 로봇을 왼쪽(반시계)으로 돌리면 yaw 값 증가
실패 체크리스트:
[ ] ls -la /dev/ttyimu → 없으면 udev 재설정
[ ] 로그에 "Calibration done!" 확인 → 없으면 10초 더 대기
[ ] python3 old_file/src/ebimu_pkg/ebimu_pkg/imu_test_1.py → raw 시리얼 데이터 직접 확인
IMU 를 쓸 때만 해당합니다. IMU 없는 기본 구성에서는 대신 바퀴 회전 부호를 확인하세요 — 좌회전 시
/odom의 yaw 가 + 로 증가해야 합니다. 뒤집혀 있으면 cartographer 가 받는 초기 추정이 매 회전마다 반대 방향이고, 이번엔 바로잡아 줄 IMU 가 없습니다.ros2 topic echo /odom --field pose.pose.orientation # 왼쪽으로 천천히 돌리면서
목표: 로봇을 왼쪽(반시계)으로 돌렸을 때 yaw 가 + 방향으로 증가하는지 확인
왜 따로 단계를 두나:
ekf.yaml은 방향을 IMU 하나에만 맡긴다 (imu0_config의 yaw 만 true, 바퀴 yaw 는 전부 false). 그래서 IMU yaw 부호가 반대면 EKF 가 로봇의 진행 방향을 거울로 뒤집어 적분한다. 그/odom이 그대로 SLAM 의 prior 로 들어가므로 지도가 통째로 망가진다. 크기(스케일)가 아니라 부호가 문제라 값만 봐서는 눈치채기 어렵다 — 그래서 회전을 직접 시켜보고 확인한다.ROS 규약(REP-103)은 반시계(왼쪽) 회전이 +z 다.
# [젯슨-1] IMU 노드 (캘리브레이션 10초 — 이 동안 로봇을 건드리지 말 것)
ros2 run ebimu_pkg ebimu_publisher \
--ros-args -p port:=/dev/ttyimu -p frame_id:=base_link
# [PC-1] yaw 를 도(°)로 계속 찍어보기
python3 - <<'EOF'
import math, rclpy
from rclpy.node import Node
from sensor_msgs.msg import Imu
rclpy.init()
n = Node('yaw_watch')
def cb(m):
q = m.orientation
yaw = math.atan2(2*(q.w*q.z + q.x*q.y), 1 - 2*(q.y*q.y + q.z*q.z))
print(f'yaw = {math.degrees(yaw):+7.2f}°', end='\r', flush=True)
n.create_subscription(Imu, '/ebimu_data', cb, 10)
rclpy.spin(n)
EOF
# [PC-2] 로봇을 손으로 천천히 왼쪽(반시계)으로 90° 돌린다
# 모터로 돌리려면:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.0}, angular: {z: 0.5}}" --rate 10 --times 30| 관측 | 판정 | 조치 |
|---|---|---|
| 왼쪽 회전 → yaw 증가(예: 0° → +90°) | ✅ 정상 | 다음 단계로 |
| 왼쪽 회전 → yaw 감소(0° → −90°) | ❌ 부호 반대 | 아래 조치 |
| 값이 튀거나 안 변함 | ❌ 통신/장착 | imu_test_1.py 로 raw 확인 |
부호가 반대일 때: src/ebimu_pkg/ebimu_pkg/ebimu_publisher.py 의 timer_callback() 에서
yaw 를 뒤집는다 (IMU 를 거꾸로 장착했거나 센서 축 규약이 반대인 경우다).
yaw_deg = d[2] - 360 if d[2] > 180 else d[2]
yaw = math.radians(yaw_deg)
yaw = -yaw # ← 부호 반대일 때만 추가STAGE 4 로 교차 검증:
tools/odom_check.py의 ② 회전 스케일 =raw_Δθ / imu_Δyaw가 음수(≈ −1.0)로 나오면 부호 불일치다. 크기는 맞는데 부호만 틀린 경우를 정확히 잡아준다.# [PC-2] 두 회전량을 겹쳐 보기 rqt_plot /odom_check/raw_theta_deg /odom_check/imu_dyaw_deg두 곡선이 같은 방향으로 움직여야 정상이다. 거울처럼 갈라지면 부호가 반대다.
통과 기준: 왼쪽 회전에 yaw 증가(+), odom_check 회전 스케일이 양수
목표: /scan 발행 확인, RViz에서 장애물 시각화
# [젯슨-1] LiDAR 노드 (RPLidar S2/S3 계열)
ros2 run sllidar_ros2 sllidar_node \
--ros-args \
-p serial_port:=/dev/rplidar \
-p serial_baudrate:=1000000 \
-p frame_id:=lidar_v1_1 \
-p scan_mode:=DenseBoost
# [PC-1]
ros2 topic hz /scan # 목표: 10 Hz 이상
# [PC-2] RViz
rviz2
# Fixed Frame: lidar_v1_1 / Add: LaserScan → Topic: /scan성공 기준: 10 Hz 이상 발행, RViz에서 주변 벽이 점으로 표시됨
실패 체크리스트:
[ ] ls -la /dev/rplidar → 없으면 udev 재설정
[ ] LiDAR 모터 회전 확인 (소리/진동)
[ ] Operation timeout 오류 → /dev/rplidar가 ttyUSB1을 가리키는지 확인
(S3는 CP2102N 칩 = ttyUSB1)
[ ] scan_mode 오류 시 → -p scan_mode:=Standard 로 변경
목표: /odom 발행 + odom→base_link TF, TF 트리 완성
선행 조건: STAGE 1 통과 (STAGE 2 = IMU 는 기본 구성에서 건너뜀)
기본(
use_imu:=false): 드라이버가/odom과 TF 를 직접 냅니다. EKF 안 띄웁니다./odom은 10 Hz(드라이버 타이머)입니다 — EKF 의 30 Hz 가 아닙니다.
use_imu:=true: 드라이버는/odom_raw만 내고 EKF 가/odom(~30 Hz)과 TF 를 냅니다. 이 경우 STAGE 2 를 먼저 통과해야 합니다.드라이버 시작 로그의 이 줄로 어느 구성인지 바로 확인할 수 있습니다:
Real Robot Driver Started (Wheel Odom Only) — odom_topic=/odom, publish_tf=True
노드는 젯슨, 확인·명령은 PC 에서 한다. 젯슨 터미널은
ssh robot또는ssh -t robot 'tmux attach -t robot'으로 연다.
# 먼저 빌드
cd ~/mobile_robot_proto_type
colcon build --packages-select ebimu_pkg relayrobot_description
source install/setup.bash
# 전체 하드웨어 launch (Motor + LiDAR + robot_state_publisher)
# use_imu:=true 를 붙이면 IMU + EKF 도 함께 뜹니다
ros2 launch relayrobot_description real_robot_260519.launch.pyIMU 노드는 시작 직후 10초 캘리브레이션합니다. 이 시간 동안 로봇을 움직이지 마세요.
로그에Calibration done!출력 후 다음 단계로 진행합니다.
# 세 토픽 모두 살아있어야 함 (PC 에서 DDS 로 젯슨 토픽을 본다)
ros2 topic hz /odom # 목표: 10 Hz (바퀴 인코더. use_imu:=true 면 /odom_raw)
ros2 topic hz /ebimu_data # 목표: 50 Hz (IMU)
ros2 topic hz /odom # 목표: 30 Hz (EKF 출력)
# 정지 상태 sanity check: x/y/yaw 모두 0에 가까워야 함
ros2 topic echo /odom --once# 0.2 m/s × 50회(5초) ≈ 1m 전진
ros2 topic pub /cmd_vel geometry_msgs/Twist \
"{linear: {x: 0.2}, angular: {z: 0.0}}" --rate 10 --times 50
# 정지 후 위치 확인
ros2 topic echo /odom --field pose.pose.position --once기대값: x ≈ 0.9~1.1
✅
rpm_scale(/600.0)은 2026-09-03 기준 확정값입니다. 임의로 바꾸지 마세요. 명령 쪽(calculate_rpms()×600)과 계측 쪽(/600.0)이 이미 일치합니다. 값이 크게 어긋난다면 팩터가 아니라 유령 거리(피드백 stale)를 먼저 의심하세요. 2026-09-13 에 드라이버에 대책을 넣었습니다 — 10Hz 명령 재전송 +feedback_timeout(0.3s) 초과 시 속도를 0 으로 간주. 드라이버 로그에모터 피드백 ...s 없음경고가 뜨면 그 경로입니다. 배경은docs/DEBUG_LOG_2026-09-03.md3절, 대책은DEBUG_LOG_2026-09-13.md12-1 절.
# odom 초기화를 위해 드라이버 재시작 권장 ([젯슨-1] 에서 Ctrl-C 후 재기동)
# 0.5 rad/s × 63회(6.3초) ≈ π/2 rad (90°)
ros2 topic pub /cmd_vel geometry_msgs/Twist \
"{linear: {x: 0.0}, angular: {z: 0.5}}" --rate 10 --times 63
ros2 topic echo /odom --field pose.pose.orientation --once
# orientation → yaw 계산:
# yaw = 2 * atan2(orientation.z, orientation.w)
# 기대값: 1.4~1.7 rad (80°~97°)✅
wheel_base = 0.22는 2026-09-03 기준 확정 실측값입니다. (예전 문서의0.165는 URDF CAD 기준값으로, 실측과 다릅니다. 코드값 0.22 가 맞습니다.) 재빌드 없이 값을 바꿔 시험만 해보려면:ros2 run relayrobot_description real_robot_driver_260519 > --ros-args -p wheel_base:=0.22 -p wheel_radius:=0.0325
ros2 run tf2_ros tf2_echo odom base_link
# translation / rotation 숫자가 출력되면 TF 발행 중 → 정상
# (기본 구성에서는 드라이버가, use_imu:=true 면 ekf_node 가 발행)성공 기준 요약:
| 테스트 | 통과 기준 | 실패 시 |
|---|---|---|
/odom Hz |
30 Hz | EKF 설정 문제 |
| 정지 sanity | x≈0, y≈0, yaw≈0 | 드라이버 초기화 이슈 |
| 직진 1m | x = 0.9~1.1 |
/600.0 팩터 조정 |
| 회전 90° | yaw = 1.4~1.7 rad |
wheel_base 실측 후 수정 |
| TF echo | transform 출력 | EKF 미기동 or ekf.yaml 경로 오류 |
실패 체크리스트:
[ ] robot_localization 설치: ros2 pkg list | grep robot_localization
[ ] /odom 발행 확인 (STAGE 1 선행)
[ ] ekf.yaml 경로:
ls $(ros2 pkg prefix relayrobot_description)/share/relayrobot_description/config/ekf.yaml
[ ] IMU 캘리브레이션 완료 로그 확인: "Calibration done!"
MPC 는 10Hz 로 /cmd_vel 을 쏜다. 그 노드가 죽거나 무선이 끊겼을 때 로봇이 서야 한다.
DDSM 은 명령 유지형이라 이 확인 없이 자율주행에 들어가면 안 된다.
선행 조건: STAGE 4 의 real_robot_260519.launch.py 가 [젯슨-1] 에서 실행 중
# [PC-1] 한 번만 쏘고 그만둔다
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.1}}" --once
# [PC-2] 정지 상태에서 x 가 더 이상 안 늘어나야 한다 (유령 거리 차단)
ros2 topic echo /odom --field pose.pose.position
# [젯슨-1] 드라이버 로그에 이 줄이 떠야 한다:
# "/cmd_vel 두절 0.5x s > 0.5s -> 정지"기대 동작: 잠깐 움직이다 0.5초 뒤 스스로 정지
| 확인 | 완료 기준 |
|---|---|
| watchdog | 명령을 끊으면 cmd_timeout(0.5s) 안에 정지 |
| 유령 거리 | 정지 후 /odom 의 x/y 가 고정 (계속 늘면 실패). 2026-09-13 watchdog 으로 해결됨 |
| 피드백 | 모터 피드백 경고(피드백 ...s 없음)가 주행 중에는 안 뜸 |
타임아웃을 바꾸려면:
--ros-args -p cmd_timeout:=0.5 -p feedback_timeout:=0.3
목표: 지도 생성, TF map→odom 발행 확인
선행 조건: STAGE 4·4-B 통과. real_robot_260519.launch.py 가 [젯슨-1] 에서 실행 중
두 개를 한 번에 띄우려면
[PC-1]에서ssh robot '~/mobile_robot_proto_type/scripts/robot-up.sh slam'— 젯슨 tmux 에1=robot2=slam창이 생긴다. 아래는 손으로 하나씩 띄우는 방법이다.
# [젯슨-2] Cartographer 실행
ros2 launch relayrobot_description cartographer.launch.py
# [PC-1] 확인
ros2 topic hz /map
ros2 run tf2_ros tf2_echo map odom
# [PC-2] 키보드 조종 (로봇이 움직인다 — 주변 확인)
ros2 run teleop_twist_keyboard teleop_twist_keyboard
# [PC-3] RViz — 준비된 프리셋을 쓴다 (Fixed Frame: map 이 이미 잡혀 있음)
rviz2 -d ~/mobile_robot_proto_type/src/relayrobot_description/config/nav.rviz지도 저장 — [PC-1] (지도 파일은 젯슨에 생긴다):
ssh robot 'ros2 run nav2_map_server map_saver_cli -f ~/robot_map'실패 체크리스트:
[ ] cartographer_ros 설치: ros2 pkg list | grep cartographer
[ ] lidar frame_id 확인: ros2 topic echo /scan --field header.frame_id
→ "lidar_v1_1" 이어야 함
[ ] STAGE 4 launch가 실행 중인지 확인
목표: 로봇 없이 PC 한 대에서 A* + Tube-MPC 를 끝까지 돌려본다. 젯슨이 꺼져 있어도, USB 가 빠져 있어도 된다. 실기에서 처음 보는 화면이 없게 만드는 단계다.
conda deactivate && ros_setup
# 실기(DOMAIN=0)와 섞이지 않도록 격리
export ROS_DOMAIN_ID=42
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
# [PC-1] 가짜 지도 + 차동구동 적분 + TF(map→odom→base_link)
python3 tools/mpc_sim.py
# [PC-2] A*
ros2 run mpc_tubempc_bridge mpc_tubempc_path_planner
# [PC-3] Tube-MPC
ros2 run mpc_tubempc_bridge mpc_tubempc_bridge --ros-args \
-p use_goal_topic:=true -p use_global_path:=true
# [PC-4] RViz — 2D Goal Pose 툴이 /mpc_goal 로 발행되게 설정돼 있다
rviz2 -d src/relayrobot_description/config/nav.rviz
# [PC-5] 목표 (RViz 클릭 대신 명령으로)
ros2 topic pub /mpc_goal geometry_msgs/msg/PoseStamped \
"{header: {frame_id: 'map'}, pose: {position: {x: 3.0, y: 2.0}, orientation: {w: 1.0}}}" --once
ros2 topic echo /mpc/status
rqt_plot /mpc/tracking_error/x /mpc/tracking_error/y /mpc/tracking_error/z★ 프레임 정합 시험 (이 리허설의 핵심):
python3 tools/mpc_sim.py --ros-args -p map_odom_x:=0.5 -p map_odom_y:=0.5 -p map_odom_yaw:=0.3실제 SLAM 은 드리프트를 보정하느라 map→odom 이 0 이 아니다. 일부러 틀어놓고도
추종이 되면 프레임 처리가 맞는 것이다. (2026-09-13 실측: offset 을 준 채 목표 도달, QP 실패 0회)
SLAM 없는 모드 확인:
ros2 run mpc_tubempc_bridge mpc_tubempc_bridge --ros-args \
-p global_frame:=odom -p use_goal_topic:=false -p use_global_path:=false \
-p goal_x:=0.5 -p goal_y:=0.5이 리허설이 못 잡아주는 것: 모터 시리얼,
/cmd_velwatchdog, 유령 거리, 센서 노이즈, 바퀴 미끄러짐. 충돌 판정도 없다(경로가 벽을 뚫으면 로봇도 통과한다 — RViz 로 눈으로 볼 것). 이것들은 STAGE 1~4 실기에서 확인한다.
목표: 목표 좌표 → A* 경로 계획 → Tube-MPC 추종
선행 조건: STAGE 5 통과, polytope·cvxpy·cvxopt 설치 완료
원격 운영이 기본이다. 제어 루프(A*·MPC)는 젯슨에서 돈다 — 10Hz 루프를 무선 너머에 두면 통신 끊김이 곧 제어 지터가 된다. PC 는 보는 쪽 + 목표를 주는 쪽이다.
# [PC-1] 젯슨에 자율주행 스택을 한 번에 띄운다 (tmux 안에서 돌아 SSH 끊겨도 산다)
ssh robot '~/mobile_robot_proto_type/scripts/robot-up.sh nav'
# 젯슨 tmux 창: 0=shell 1=robot 2=slam 3=planner 4=mpc
# 로그 보기: ssh -t robot 'tmux attach -t robot' (빠져나올 때 Ctrl-b 누르고 d)
# [PC-1] 상태 — 이 창은 계속 켜둔다
ros2 topic echo /mpc/status
# [PC-2] 추종 오차
rqt_plot /mpc/tracking_error/x /mpc/tracking_error/y /mpc/tracking_error/z
# [PC-3] RViz — 지도 / 팽창지도 / A* 경로 / MPC 호라이즌을 한 화면에
rviz2 -d ~/mobile_robot_proto_type/src/relayrobot_description/config/nav.rviz
# [PC-4] 목표 발행 (1~2m 이내 짧은 거리부터)
# RViz 의 2D Goal Pose 툴을 클릭해도 /mpc_goal 로 나간다.
ros2 topic pub /mpc_goal geometry_msgs/msg/PoseStamped \
"{header: {frame_id: 'map'}, pose: {position: {x: 1.0, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}" \
--once손으로 하나씩 띄우려면 (robot-up.sh nav 대신 — 아래 4개는 전부 젯슨):
# [젯슨-1]
ros2 launch relayrobot_description real_robot_260519.launch.py
# [젯슨-2]
ros2 launch relayrobot_description cartographer.launch.py
# [젯슨-3]
ros2 run mpc_tubempc_bridge mpc_tubempc_path_planner
# [젯슨-4]
ros2 run mpc_tubempc_bridge mpc_tubempc_bridge --ros-args \
-p use_goal_topic:=true -p use_global_path:=true \
-p velocity_limit:=0.1 -p omega_limit:=1.0 -p horizon:=4왜 MPC 를 PC 에서 돌리지 않나: 제어 루프가 10Hz 다. 무선 너머에 두면 통신 끊김이 곧 제어 지터가 된다. 젯슨에서 돌리고 PC 는
/mpc/*로 관찰만 한다.
STAGE 4-B(teleop)에서는 /cmd_vel 발행을 그만두면 watchdog 이 세웠다. 자율주행 모드는 다르다.
MPC 가 10Hz 로 /cmd_vel 을 계속 쏘고 있어서(정지 명령도 포함) 조작자가 "멈출 발행" 이 없다.
MPC 를 죽여야 한다.
# [PC-1] MPC 만 끄기 — 가장 빠르다
ssh robot 'tmux kill-window -t robot:mpc'
# [PC-1] 전부 끄기
ssh robot '~/mobile_robot_proto_type/scripts/robot-up.sh stop'둘 다 MPC 가 멎으므로 0.5초 뒤 드라이버 watchdog 이 모터를 세운다. 주행 전에 이 명령을 미리 터미널에 쳐두고 엔터만 남겨두는 것을 권한다.
실패 체크리스트:
[ ] ImportError (TubeMPCPlanner) → colcon build --symlink-install 재빌드
[ ] ImportError (polytope/cvxpy) → pip3 install polytope cvxpy osqp cvxopt
[ ] /global_path 없음 → Cartographer 실행 확인 (/map 수신 대기 중)
[ ] "Goal cell is not free" → 팽창 반영 후 막힌 좌표. /inflated_map 을 RViz 에서 보고 다른 지점
[ ] "TF map→base_link 없음" (/mpc/status = NO_POSE) → Cartographer 미기동 또는 PC·젯슨 시계 불일치
[ ] /mpc/status = QP_FAILED 연속 → error_yaw_limit 확인 (아래 트러블슈팅)
[ ] /mpc/status = NO_PATH → A* 가 경로를 못 냄. 계획기 로그 + /inflated_map 확인.
※ 경로가 없으면 MPC 는 "선다". 지도를 무시하고 목표로 직진하지 않는다 (2026-09-13 수정)
- 최적 위치: 두 바퀴 축의 정중앙 (회전 중심)
- 방향 (REP-103): X축 = 전진 방향, Y축 = 왼쪽, Z축 = 위
- 수평 유지: 기울어지면 중력 가속도가 섞임
- 진동 절연: 얇은 고무 패드나 폼 테이프 위에 부착 권장
DDSM HAT(B)는 Arduino / ESP32 두 모드를 지원합니다. 이 프로젝트는 ESP32 모드 필수.
- ESP32 모드 →
/dev/ttyACM0으로 인식 - 모드가 다르면 포트가 잡혀도 JSON 명령 무시됨
DDSM 모터는 공장 출고 시 ID가 1 또는 2로 설정돼 있고, RS485 버스에 두 모터가 함께 물립니다.
드라이버는 id=1(오른쪽), id=2(왼쪽)로 각 모터에 따로 명령을 보내므로 두 모터의 ID가 서로 달라야 합니다.
증상: 두 바퀴가 명령에 똑같이 반응(동시에 회전) → 두 모터의 ID가 같은 상태.
ID 확인/변경 도구: motor_id_check.py
# 1. 모터를 '한 개만' HAT에 연결 (반드시 1개! 2개면 둘 다 같은 ID로 바뀜)
# 2. HAT 전원 ON 후 현재 ID 조회 (절대경로라 어느 폴더에서든 실행 가능)
python3 ~/mobile_robot_proto_type/old_file/src/relayrobot_driver/relayrobot_driver/motor_id_check.py
# 3. 이 모터를 원하는 ID로 변경 (예: 2번)
python3 ~/mobile_robot_proto_type/old_file/src/relayrobot_driver/relayrobot_driver/motor_id_check.py 2
# 4. HAT 전원 OFF → ON (전원 사이클당 1회만 변경 가능, 끄면 저장됨)
# 5. 다른 모터로 교체 후 위 과정 반복하여 ID=1 부여왜 한 번에 안 바뀌나? DDSM 펌웨어는 ID 변경 프레임(
T:10011)을 5번 연속 받아야 저장합니다
(ddsm_ctrl.cpp의ddsm_change_id→for(i<5)루프).motor_id_check.py는 5번 반복 전송하도록 구현됨.
왜 1개만 연결? 펌웨어 명세상 여러 모터가 연결된 상태에서 ID를 바꾸면 연결된 모든 모터가 같은 ID로 바뀝니다.
| cmd 값 | 실제 RPM |
|---|---|
| 100 | 10 RPM |
| 600 | 60 RPM |
공식:
실제 RPM = cmd ÷ 10
# 두 모터가 각각 R 단독 / L 단독 / 전진 순서로 도는지 확인 (어느 폴더에서든 실행 가능)
python3 ~/mobile_robot_proto_type/old_file/src/relayrobot_driver/relayrobot_driver/motor_test_1.py모터 명령 간 지연 시간을 10ms로 최적화. (motor_drive_1.py 적용 완료)
| 좌표계 | 의미 | 책임 노드 |
|---|---|---|
base_link |
로봇의 물리적 중심 | robot_state_publisher |
odom |
출발점 기준 상대 위치 | ekf_filter_node |
map |
실제 세계(지도) 기준 위치 | cartographer |
odom_raw vs odom를 분리한 이유:
odom_raw: 순수 휠 인코더 기반 (미끄러짐 오차 포함)odom: odom_raw + IMU를 EKF로 융합 → SLAM의 map→odom 보정 실시간 반영
제어기가 /odom을 쓰는데 SLAM 보정이 되는 이유:
EKF가 odom→base_link를 발행하고, SLAM이 map→odom을 보정합니다.
전역 플래너는 두 TF를 곱해 map 기준 로봇 위치를 계산하므로, 결국 지도의 목표점에 정확히 도착합니다.
# Robot PC / Remote PC 모두 동일한 값으로 설정 (0~232)
echo 'export ROS_DOMAIN_ID=30' >> ~/.bashrc
source ~/.bashrc연결 확인:
# Remote PC에서
ping [Robot_IP]
ros2 topic list # Robot PC에서 노드 실행 중일 때 토픽이 보이면 성공python3 ~/mobile_robot_proto_type/old_file/src/relayrobot_driver/relayrobot_driver/motor_test_1.py
# "Connected" 출력 안 되면: USB → ESP32 모드 점퍼 → 전원 순서로 확인ros2 pkg list | grep robot_localization
# 없으면: sudo apt install ros-$ROS_DISTRO-robot-localization
# (use_imu:=true 로 EKF 를 쓸 때만 필요. 기본 구성에서는 EKF 자체를 안 띄웁니다)sudo apt install ros-$ROS_DISTRO-slam-toolbox
ros2 launch slam_toolbox online_async_launch.py \
params_file:=old_file/src/relayrobot_description/my_slam_params.yamlros2 run tf2_ros tf2_monitor
ros2 topic info /tf --verbose # odom→base_link 발행 노드가 하나인지 확인2026-09-22 기준, odom→base_link 발행자는 use_imu 가 정합니다.
드라이버 publish_tf |
ekf_node |
|
|---|---|---|
use_imu:=false (기본) |
True | 안 띄움 |
use_imu:=true |
False | 띄움 |
발행자가 둘이 되는 흔한 경로 두 가지:
ros2 run relayrobot_driver main_driver(=old_file/.../motor_node_1.py) —odom과 TF 를 직접 쏩니다. 띄우지 마세요. 기구학 상수도 낡았습니다. 젯슨에 예전install/relayrobot_driver/가 남아 있으면 아직 실행됩니다 →rm -rf build install log후 재빌드.hw_testGUI 의 EKF Start — 기본 구성에서 누르면 드라이버와 겹칩니다. 그래서use_ekf=False일 때는 막아뒀습니다.
# 제약 완화
ros2 run mpc_tubempc_bridge mpc_tubempc_bridge \
--ros-args -p velocity_limit:=0.1 -p horizon:=4증상: A* 경로는 잘 나오는데 MPC 가 100% QP_FAILED. 로봇이 조금 가다 선다.
원인: heading 오차 상태 제약이 너무 좁았다. error_yaw_limit 은 tube 크기만큼
타이트닝되므로 예전 기본값 0.3 은 실효 0.25 rad(약 14°) 가 된다. 그런데 A* 경로를
따라갈 때 첫 참조점의 접선 방향과 로봇의 현재 방향은 출발 시 쉽게 20~30° 벌어진다
→ 첫 사이클부터 제약 위반이라 QP 가 항상 infeasible. 실측한 실패 경계가 정확히 0.25 rad 였다.
해결: 현재 기본값은 error_yaw_limit:=3.2(사실상 무제한)다. heading 오차는 안전 제약이
아니다 — 돌아서 줄이면 되는 값이다. 실제로 지켜야 할 것은 위치 오차(tube)이고
그건 error_xy_limit 과 ancillary 되먹임이 담당한다.
# 값을 확인하고 싶을 때
ros2 param get /mpc_tubempc_bridge error_yaw_limit # 3.2 여야 정상QP 부하는 걱정하지 않아도 된다. 변수 8개·제약 40행짜리 작은 문제다. PC(i5-12400F) 실측 평균 4.5 ms — 10Hz 루프 예산 100 ms 의 5%. 대부분이 cvxpy 의 파이썬 오버헤드라 젯슨 ARM 에서 몇 배 느려져도 여유가 있다.
# 포트 식별 재확인
for dev in /dev/ttyUSB* /dev/ttyACM*; do
echo "=== $dev ===" && udevadm info -a -n "$dev" | grep 'ATTRS{product}' | head -1
done
# 규칙 재적용
sudo cp ~/mobile_robot_proto_type/src/relayrobot_description/scripts/99-robot-devices.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
# USB 뽑았다가 다시 꽂기readline()이\r\n을 두 줄로 읽어0.003.001처럼 붙어서 파싱 실패- 해결:
.replace('\r','').replace('\n','')+*시작 확인 + 9개 필드 검증 - YAW wraparound(0~360°) →
-180~+180°변환 추가 - 10초 정지 캘리브레이션으로 ACC/GYRO 바이어스 자동 제거
- ESP32 모드 미설정으로 JSON 명령 무응답
cmd 100 = 10 RPM스케일링 주의 필요
- 증상:
id=1/id=2어느 명령을 보내도 두 바퀴가 똑같이 회전 - 원인: 두 모터가 같은 ID. (두 모터를 동시에 연결한 채 ID를 변경하면 둘 다 같은 ID로 바뀜)
- 추가 함정: DDSM은 ID 변경 프레임(
T:10011)을 5번 연속 받아야 저장 → 1회 전송 시 무시됨 - 해결:
motor_id_check.py추가 (조회T:10031/ 변경T:100115회 반복). 모터를 1개씩만 연결해 각각 ID=1, ID=2 부여 후 전원 사이클 - 참고:
T:11002는 HAT(B)에서 모터 Enable 명령 (HAT(A) 펌웨어의json_cmd.h와 번호 매핑이 다름)
- 모터 드라이버와 EKF 노드가 동시에
odom→base_linkTF 발행 - 해결: 드라이버의 TF 브로드캐스터 비활성화, EKF만 발행
- 제어기가 목표치만 던지고 현재 위치 피드백 없음
- 해결:
/odom구독 추가, 실시간 Distance/Angle Error 피드백 루프 완성
증상: STAGE 6 실행해도 /cmd_vel이 항상 0, 로봇 정지. 또는 MPC QP failed 로그 반복.
원인 (4중 복합):
- horizon 불일치 —
construct_augmentemd_model이 4스텝(A0~A3) 하드코딩인데 기본값·README가horizon=6→ 행렬 차원 불일치로 QP가 매 사이클 크래시. - 추종오차가 구조적으로 0 — 참조 궤적
qRef[:,0]을 매 사이클 현재 위치로 재설정 →e_k = compute_error(current, current) = 0→ 비용f_qp=0→u_mpc=0. - 피드포워드 누락 — 참조 입력
uRef(목표로 향하는 v,ω)를 계산만 하고cmd_vel에 안 더함.cmd = u_act ≈ 0. - tube 보정 무효 + 제약 공집합 — ancillary 항
K(e_act−e_nom)이 항상 0열(0)만 읽어 무효. 게다가R=0.01로 LQR gainK≈10→ tube 타이트닝량K·e(≈2~4)가 입력 한계(±0.5)를 초과 → 타이트닝된 입력집합이 공집합 → QP 영구 infeasible.
해결:
- horizon을 4로 정렬 (기본값/docstring/README STAGE 6)
- 참조를 월드 프레임에 고정:
qRef[:,0]=최근접 경로점, 이후 호라이즌은 경로를 arc-length만큼 전진 → 실제 cross-track/heading 오차 발생 - 피드포워드 복원:
cmd = uRef + 보정량 - tube 명목 상태
e_nom을 사이클 간 전파(e_nom = A0·e_nom + B·u_nom), 목표/경로 갱신 시 리셋 → ancillary 실제 작동 - 목표 반경(
goal_tolerance) 내 진입·QP 실패 시 zero stop - 플래너 튜닝:
R: 0.01→5.0(K 완화),tube e_min: ±0.2→±0.05→K·e < 입력한계보장으로 QP feasibility 확보 - 검증: 실제
TubeMPCPlanner로 1사이클 — 경로 위cmd=[0.20, 0.00], 경로 이탈 시 QP가 풀려 경로 쪽으로 조향(cmd=[0.12, -0.32])