3 Commits

Author SHA1 Message Date
robin cc68abac4d Merge feat/radiomaster-rc-control: IMU integration + reliability fixes
Brings in the x86-validated IMU integration (Phases 1-5a), udev-stable
serial device names, motor/driver temperature monitoring, and RC/
kinematics fixes from the completed x86 branch. Resolved conflicts by
keeping xbox_motor_control_cpp untracked (build artifact) and merging
both branches' .gitignore entries.
2026-08-20 13:30:30 +09:00
robin e817ec005c Add .gitignore and untrack build artifacts
xbox_motor_control_cpp is the Makefile build output (TARGET) and
.DS_Store is a macOS metadata file; neither should be version controlled.
2026-08-20 13:26:44 +09:00
robin 9a97cf9022 Add IMU integration (Phases 1-5a) and RC/kinematics reliability fixes
IMU integration (WitMotion HWT905-RS232, doc/06-imu-integration-plan.md):
- Phase 1: raw accel/gyro/angle observation, power-on-relative yaw offset
- Phase 2: x,y odometry + wheel-vs-IMU omega residual, using a self-fused
  heading (wheel encoder omega + raw gyro_z average) instead of the IMU's
  own onboard fused yaw, which field logs showed disagreeing with its own
  raw gyro sign ~30% of the time during turns
- Phase 3: straight-line heading-hold PI trim, active only when steering
  is centered and no lidar dodge is in progress, capped and field-tuned
- Phase 4: whole-body slip detection (commanded vs IMU-measured omega
  ratio) that temporarily scales down v_x/omega, independent of the
  per-wheel current-based diagnostics
- Phase 5a: k_skid/effective_w replaced with values fitted from real
  wheel-vs-IMU logs (0.406->0.51, 0.684->0.87) instead of the geometric
  formula, which field data showed under-driving every turn

RC receiver: switched from ttyUSB* guessing to udev-stable device names
(ttyMOTOR/ttyRC/ttyIMU), wired through run_4wd.sh and set_low_latency.sh.

Motor driver: read motor/driver temperature registers (0x20A4/0x20B0)
for proactive overheat visibility in the HUD/CSV.

Control fixes:
- Airborne-wheel zeroing no longer applies during spin turns, where
  reaction-torque-driven diagonal load transfer was misclassified as
  wheels leaving the ground and cutting spin torque in half
- jerkLimitedStep's instant-acceleration path now only fires for
  same-direction speed increases; sign reversals (RC noise/deadzone
  jitter near a stop, or a genuine direction change) go through the
  jerk-limited path instead of snapping across zero

Auto CSV logging (logs/, gitignored) extended with encoder ticks, IMU,
odometry, heading-hold, slip, and temperature columns for field analysis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 13:20:43 +09:00
7 changed files with 902 additions and 57 deletions
Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
xbox_motor_control_cpp
.DS_Store
logs/
+236
View File
@@ -0,0 +1,236 @@
// HWT905-RS232 (WitMotion) IMU reader — Phase 1: pure observation, no control loop.
// Reads the sensor's active-push protocol (0x55-prefixed packets) over a plain
// serial port (RS232-over-USB, e.g. /dev/ttyUSB0) and prints accel/gyro/angle to
// stdout, optionally logging to CSV. Not Modbus — the RS485 variant uses Modbus,
// this RS232 variant does not.
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <fstream>
#include <iostream>
#include <string>
#include <termios.h>
#include <unistd.h>
namespace {
constexpr uint8_t kFrameHeader = 0x55;
constexpr uint8_t kTypeAccel = 0x51;
constexpr uint8_t kTypeGyro = 0x52;
constexpr uint8_t kTypeAngle = 0x53;
constexpr uint8_t kTypeMag = 0x54;
struct ImuState {
double accel[3] = {0, 0, 0}; // g
double gyro[3] = {0, 0, 0}; // deg/s
double angle[3] = {0, 0, 0}; // deg (roll, pitch, yaw)
double mag[3] = {0, 0, 0}; // raw counts
bool has_accel = false, has_gyro = false, has_angle = false, has_mag = false;
};
int16_t toInt16(uint8_t lo, uint8_t hi) {
return static_cast<int16_t>(static_cast<uint16_t>(lo) | (static_cast<uint16_t>(hi) << 8));
}
speed_t baudToSpeed(int baud) {
switch (baud) {
case 4800: return B4800;
case 9600: return B9600;
case 19200: return B19200;
case 38400: return B38400;
case 57600: return B57600;
case 115200: return B115200;
case 230400: return B230400;
default:
std::cerr << "[경고] 지원하지 않는 baud " << baud << ", 115200으로 대체\n";
return B115200;
}
}
int openSerialPort(const std::string &port, int baud) {
int fd = open(port.c_str(), O_RDWR | O_NOCTTY | O_NDELAY);
if (fd < 0) {
std::cerr << "[오류] 포트 열기 실패: " << port << " (" << std::strerror(errno) << ")\n";
return -1;
}
fcntl(fd, F_SETFL, 0); // switch back to blocking reads
struct termios options;
if (tcgetattr(fd, &options) != 0) {
std::cerr << "[오류] tcgetattr 실패\n";
close(fd);
return -1;
}
speed_t speed = baudToSpeed(baud);
cfsetispeed(&options, speed);
cfsetospeed(&options, speed);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB;
options.c_cflag &= ~CSTOPB;
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;
options.c_cflag &= ~CRTSCTS;
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
options.c_iflag &= ~(IXON | IXOFF | IXANY);
options.c_iflag &= ~(INLCR | ICRNL);
options.c_oflag &= ~OPOST;
options.c_cc[VMIN] = 1;
options.c_cc[VTIME] = 5; // 0.5s inter-byte timeout
tcflush(fd, TCIFLUSH);
if (tcsetattr(fd, TCSANOW, &options) != 0) {
std::cerr << "[오류] tcsetattr 실패\n";
close(fd);
return -1;
}
return fd;
}
// Parses one validated 11-byte WitMotion frame (frame[0]==0x55, checksum ok)
// into the running ImuState. Returns true if this frame completed an
// accel+gyro+angle group worth printing (i.e. it was an angle frame).
bool applyFrame(const uint8_t *frame, ImuState &state) {
const uint8_t type = frame[1];
switch (type) {
case kTypeAccel:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.accel[i] = raw / 32768.0 * 16.0; // g
}
state.has_accel = true;
return false;
case kTypeGyro:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.gyro[i] = raw / 32768.0 * 2000.0; // deg/s
}
state.has_gyro = true;
return false;
case kTypeAngle:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.angle[i] = raw / 32768.0 * 180.0; // deg
}
state.has_angle = true;
return true;
case kTypeMag:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
state.mag[i] = raw;
}
state.has_mag = true;
return false;
default:
return false; // time/quaternion/GPS frames etc. — ignored in Phase 1
}
}
} // namespace
int main(int argc, char **argv) {
std::setvbuf(stdout, nullptr, _IOLBF, 4096); // line-buffer even when piped
std::string port = "/dev/ttyUSB0";
int baud = 9600; // HWT905-232 factory default (confirmed against this unit)
std::string log_path;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--port" && i + 1 < argc) {
port = argv[++i];
} else if (arg == "--baud" && i + 1 < argc) {
baud = std::stoi(argv[++i]);
} else if (arg == "--log" && i + 1 < argc) {
log_path = argv[++i];
} else if (arg == "--help") {
std::cout << "사용법: " << argv[0]
<< " [--port /dev/ttyUSB0] [--baud 115200] [--log out.csv]\n";
return 0;
}
}
int fd = openSerialPort(port, baud);
if (fd < 0) return 1;
std::ofstream log_file;
if (!log_path.empty()) {
log_file.open(log_path);
if (!log_file) {
std::cerr << "[오류] 로그 파일 열기 실패: " << log_path << "\n";
return 1;
}
log_file << "t_s,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps,roll_deg,pitch_deg,yaw_deg\n";
}
std::cout << "포트 " << port << " @ " << baud << "bps 에서 IMU 데이터 수신 시작 "
<< "(Ctrl+C로 종료)\n";
std::cout.flush();
const auto t_start = std::chrono::steady_clock::now();
ImuState state;
uint8_t buf[11];
size_t buf_len = 0;
while (true) {
uint8_t byte;
ssize_t n = read(fd, &byte, 1);
if (n <= 0) {
if (n < 0 && errno != EAGAIN && errno != EINTR) {
std::cerr << "[오류] 시리얼 읽기 실패: " << std::strerror(errno) << "\n";
break;
}
continue;
}
if (buf_len == 0) {
if (byte != kFrameHeader) continue; // resync: wait for header
buf[buf_len++] = byte;
continue;
}
buf[buf_len++] = byte;
if (buf_len < 11) continue;
// Full 11-byte candidate frame collected — validate checksum.
uint8_t sum = 0;
for (int i = 0; i < 10; ++i) sum += buf[i];
if (sum != buf[10]) {
// Checksum mismatch: resync by sliding one byte and rescanning for 0x55.
std::cerr << "[경고] 체크섬 불일치, 프레임 폐기\n";
buf_len = 0;
continue;
}
bool print_now = applyFrame(buf, state);
buf_len = 0;
if (print_now && state.has_accel && state.has_gyro && state.has_angle) {
double t_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - t_start).count();
std::printf(
"t=%7.3fs accel[g]=(%+.3f,%+.3f,%+.3f) gyro[dps]=(%+7.2f,%+7.2f,%+7.2f) "
"angle[deg]=(roll=%+7.2f,pitch=%+7.2f,yaw=%+7.2f)\n",
t_s, state.accel[0], state.accel[1], state.accel[2], state.gyro[0], state.gyro[1],
state.gyro[2], state.angle[0], state.angle[1], state.angle[2]);
if (log_file) {
log_file << t_s << ',' << state.accel[0] << ',' << state.accel[1] << ','
<< state.accel[2] << ',' << state.gyro[0] << ',' << state.gyro[1] << ','
<< state.gyro[2] << ',' << state.angle[0] << ',' << state.angle[1] << ','
<< state.angle[2] << '\n';
log_file.flush();
}
}
}
close(fd);
return 0;
}
+16 -12
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 1-Click Launch Script for C++ 4WD Motor Control with Mid-360S LiDAR Avoidance # 1-Click Launch Script for C++ 4WD Motor Control with Mid-360S LiDAR Avoidance
# 현재 고정 배선: 모터 드라이버 = ttyUSB1, RC 수신기(XR1) = ttyUSB0 # udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된 심볼릭
PORT="${1:-/dev/ttyUSB1}" # 링크 사용 — ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름들은 고정이다.
RC_PORT="${RC_PORT:-/dev/ttyUSB0}" PORT="${1:-/dev/ttyMOTOR}"
RC_PORT="${RC_PORT:-/dev/ttyRC}"
IMU_PORT="${IMU_PORT:-/dev/ttyIMU}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" || exit 1 cd "$SCRIPT_DIR" || exit 1
@@ -12,11 +14,12 @@ echo "=================================================================="
echo " 🚀 ZLAC8015D 4WD + Livox Mid-360S 라이다 원클릭 런치 시스템" echo " 🚀 ZLAC8015D 4WD + Livox Mid-360S 라이다 원클릭 런치 시스템"
echo "==================================================================" echo "=================================================================="
# 1. USB Latency 1ms 단축 (모터 포트 + RC 수신기 포트 둘 다) # 1. USB Latency 1ms 단축 (모터/RC/IMU 포트 전부)
if [ -f "./set_low_latency.sh" ]; then if [ -f "./set_low_latency.sh" ]; then
echo "[1/2] USB 시리얼 포트 지연 시간 1ms 최적화 적용 중... (모터: $PORT / RC: $RC_PORT)" echo "[1/2] USB 시리얼 포트 지연 시간 1ms 최적화 적용 중... (모터: $PORT / RC: $RC_PORT / IMU: $IMU_PORT)"
./set_low_latency.sh "$PORT" ./set_low_latency.sh "$PORT"
./set_low_latency.sh "$RC_PORT" ./set_low_latency.sh "$RC_PORT"
./set_low_latency.sh "$IMU_PORT"
fi fi
# 2. C++ 바이너리 존재 여부 확인 및 컴파일 # 2. C++ 바이너리 존재 여부 확인 및 컴파일
@@ -26,16 +29,17 @@ if [ ! -f "./xbox_motor_control_cpp" ]; then
fi fi
echo "==================================================================" echo "=================================================================="
echo " [시작] C++ 100Hz 초저지연 4WD RC(RadioMaster Pocket + XR1) + 라이다 장애물 회피 시작" echo " [시작] C++ 100Hz 초저지연 4WD RC(RadioMaster Pocket + XR1) + IMU(HWT905) + 라이다 장애물 회피 시작"
echo " - 모터 포트: $PORT / RC 수신기 포트: $RC_PORT" echo " - 모터 포트: $PORT / RC 수신기 포트: $RC_PORT / IMU 포트: $IMU_PORT"
echo " - 라이다 기능 비활성화: --no_lidar 옵션" echo " - 라이다 기능 비활성화: --no_lidar 옵션 / IMU 비활성화: --no_imu 옵션"
echo " - 매 주행마다 logs/에 CSV 로그 자동 저장 (끄려면 --no_log)"
echo " - 비상 정지: CH5 브레이크 스위치 또는 Ctrl+C" echo " - 비상 정지: CH5 브레이크 스위치 또는 Ctrl+C"
echo "==================================================================" echo "=================================================================="
# 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능. --rc_port를 다시 넘기면 # 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능. --rc_port/--imu_port를 다시
# 아래 기본값을 덮어쓸 수 있다 — 인자 파싱은 뒤에 온 값이 우선 적용됨) # 넘기면 아래 기본값을 덮어쓸 수 있다 — 인자 파싱은 뒤에 온 값이 우선 적용됨)
if [ $# -gt 1 ]; then if [ $# -gt 1 ]; then
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" "${@:2}" ./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" --imu_port "$IMU_PORT" "${@:2}"
else else
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" ./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" --imu_port "$IMU_PORT"
fi fi
+4 -1
View File
@@ -8,7 +8,10 @@ if [ ! -e "$PORT" ]; then
exit 1 exit 1
fi fi
DEV_NAME=$(basename "$PORT") # /dev/ttyMOTOR 같은 udev 고정 심볼릭 링크로 넘어올 수 있으므로, sysfs 조회에
# 필요한 실제 장치명(ttyUSB0 등)으로 반드시 풀어준다 — 안 그러면
# /sys/bus/usb-serial/devices/ttyMOTOR 경로가 존재하지 않아 항상 폴백으로 샌다.
DEV_NAME=$(basename "$(readlink -f "$PORT")")
LATENCY_PATH="/sys/bus/usb-serial/devices/$DEV_NAME/latency_timer" LATENCY_PATH="/sys/bus/usb-serial/devices/$DEV_NAME/latency_timer"
if [ -f "$LATENCY_PATH" ]; then if [ -f "$LATENCY_PATH" ]; then
+635 -36
View File
@@ -5,7 +5,9 @@
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <csignal> #include <csignal>
#include <ctime>
#include <fcntl.h> #include <fcntl.h>
#include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <map> #include <map>
@@ -315,33 +317,39 @@ public:
bool readFeedback(float &l_fb, float &r_fb, int32_t &l_tick, bool readFeedback(float &l_fb, float &r_fb, int32_t &l_tick,
int32_t &r_tick, float &l_torque_a, float &r_torque_a, int32_t &r_tick, float &l_torque_a, float &r_torque_a,
uint16_t &err_l, uint16_t &err_r) { uint16_t &err_l, uint16_t &err_r, int &l_temp_c,
int &r_temp_c) {
std::vector<uint16_t> regs; std::vector<uint16_t> regs;
// 0x20A5~0x20AE 10레지스터 연속 읽기: 에러코드(0x20A5/0x20A6)부터 // 0x20A4~0x20AE 11레지스터 연속 읽기: 모터온도(0x20A4)부터 에러코드
// 포지션 틱, RPM 피드백, 실제 토크(전류)까지 한 번의 통신으로 확보. // (0x20A5/0x20A6), 포지션 틱, RPM 피드백, 실제 토크(전류)까지 한 번의
// 통신으로 확보. 0x20A4는 기존 읽기 범위 바로 앞이라 추가 통신 없이
// 온도까지 같이 받아온다(과열 사전감지용, doc/02 §읽기전용상태 참고).
// 무부하(공중에 뜬) 바퀴는 전류가 급격히 낮아지므로 슬립 진단에 사용, // 무부하(공중에 뜬) 바퀴는 전류가 급격히 낮아지므로 슬립 진단에 사용,
// 에러코드는 과전류/과부하 등 드라이버 알람 발생 시 원인 진단에 사용. // 에러코드는 과전류/과부하 등 드라이버 알람 발생 시 원인 진단에 사용.
if (port_->readRegs(slave_id_, 0x20A5, 10, regs)) { if (port_->readRegs(slave_id_, 0x20A4, 11, regs)) {
err_l = regs[0]; l_temp_c = static_cast<int8_t>((regs[0] >> 8) & 0xFF);
err_r = regs[1]; r_temp_c = static_cast<int8_t>(regs[0] & 0xFF);
err_l = regs[1];
err_r = regs[2];
// 수정: uint32_t -> int32_t 캐스팅은 2의 보수 표현에서 안전하게 부호가 // 수정: uint32_t -> int32_t 캐스팅은 2의 보수 표현에서 안전하게 부호가
// 재해석됨. 기존의 "val - 0x100000000ULL" 방식은 uint64_t 승격 후 // 재해석됨. 기존의 "val - 0x100000000ULL" 방식은 uint64_t 승격 후
// int32_t로 축소 캐스팅하는 구현정의 동작(현실적으로는 대부분 동작하지만 // int32_t로 축소 캐스팅하는 구현정의 동작(현실적으로는 대부분 동작하지만
// 명확성/이식성이 떨어짐)이라 제거함. // 명확성/이식성이 떨어짐)이라 제거함.
uint32_t val_l = (static_cast<uint32_t>(regs[2]) << 16) | regs[3]; uint32_t val_l = (static_cast<uint32_t>(regs[3]) << 16) | regs[4];
l_tick = static_cast<int32_t>(val_l); l_tick = static_cast<int32_t>(val_l);
uint32_t val_r = (static_cast<uint32_t>(regs[4]) << 16) | regs[5]; uint32_t val_r = (static_cast<uint32_t>(regs[5]) << 16) | regs[6];
r_tick = static_cast<int32_t>(val_r); r_tick = static_cast<int32_t>(val_r);
int16_t vl = static_cast<int16_t>(regs[6]); int16_t vl = static_cast<int16_t>(regs[7]);
int16_t vr = static_cast<int16_t>(regs[7]); int16_t vr = static_cast<int16_t>(regs[8]);
l_fb = static_cast<float>(vl) * 0.1f; // 0.1RPM 단위 -> RPM l_fb = static_cast<float>(vl) * 0.1f; // 0.1RPM 단위 -> RPM
r_fb = static_cast<float>(vr) * 0.1f; r_fb = static_cast<float>(vr) * 0.1f;
int16_t tl = static_cast<int16_t>(regs[8]); int16_t tl = static_cast<int16_t>(regs[9]);
int16_t tr = static_cast<int16_t>(regs[9]); int16_t tr = static_cast<int16_t>(regs[10]);
l_torque_a = static_cast<float>(tl) * 0.1f; // 0.1A 단위 -> A l_torque_a = static_cast<float>(tl) * 0.1f; // 0.1A 단위 -> A
r_torque_a = static_cast<float>(tr) * 0.1f; r_torque_a = static_cast<float>(tr) * 0.1f;
return true; return true;
@@ -349,6 +357,17 @@ public:
return false; return false;
} }
// 드라이버 자체 온도(0x20B0, 0.1℃). 열 시정수가 초 단위로 느려서 매 틱
// 읽을 필요가 없어 별도 저빈도 폴링용으로 분리(readFeedback 연속범위와
// 떨어져 있어 합치면 통신 1회가 늘어남).
bool readDriverTemp(float &temp_c) {
std::vector<uint16_t> regs;
if (!port_->readRegs(slave_id_, 0x20B0, 1, regs))
return false;
temp_c = static_cast<int16_t>(regs[0]) * 0.1f;
return true;
}
// 정격전류(0x2033/0x2063)·최대전류(0x2034/0x2064) 실측 조회. 채널당 // 정격전류(0x2033/0x2063)·최대전류(0x2034/0x2064) 실측 조회. 채널당
// 2레지스터씩 떨어져 있어 L/R 두 번 읽음(설정값이라 시작 시 1회만 조회). // 2레지스터씩 떨어져 있어 L/R 두 번 읽음(설정값이라 시작 시 1회만 조회).
bool readCurrentLimits(uint16_t &rated_l, uint16_t &max_l, uint16_t &rated_r, bool readCurrentLimits(uint16_t &rated_l, uint16_t &max_l, uint16_t &rated_r,
@@ -628,7 +647,9 @@ struct termios2 { // <asm/termbits.h>의 커널 ABI와 동일 레이아웃 (glib
}; };
struct RcConfig { struct RcConfig {
std::string port = "/dev/ttyUSB0"; // 현재 고정 배선: RC 수신기(XR1) = ttyUSB0 // udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된
// 심볼릭 링크. ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름은 고정.
std::string port = "/dev/ttyRC";
int baud = 420000; // CRSF 표준 보드레이트 int baud = 420000; // CRSF 표준 보드레이트
int ch_steer = 1; // CH1: 좌우 조향 int ch_steer = 1; // CH1: 좌우 조향
@@ -914,20 +935,285 @@ private:
} }
}; };
// -----------------------------------------------------------------------------
// WitMotion HWT905-RS232 IMU 리더 (doc/06-imu-integration-plan.md Phase 1)
// RS485/Modbus 변형과 달리 이 RS232 변형은 액티브 푸시 방식 바이너리 프로토콜을
// 쓴다: [0]=0x55 헤더, [1]=타입(0x51 가속도/0x52 자이로/0x53 각도/0x54 지자기),
// [2..9]=int16×4(LE, XYZ+예약), [10]=체크섬(0~9바이트 합의 하위 8비트).
// imu/imu_test에서 실기로 검증된 프로토콜/스케일을 그대로 이식했다.
// Phase 1 범위: 순수 관측만 한다 — 제어 루프에는 절대 개입하지 않는다.
// -----------------------------------------------------------------------------
struct ImuConfig {
// udev 규칙으로 고정된 심볼릭 링크 (모터=/dev/ttyMOTOR, RC=/dev/ttyRC와 별도)
std::string port = "/dev/ttyIMU";
int baud = 9600; // HWT905-232 공장 출하 기본값(실기 확인됨)
int failsafe_timeout_ms = 500; // 이 시간 이상 미수신 시 미연결로 표시
};
class ImuReader {
public:
struct Snapshot {
double accel[3] = {0, 0, 0}; // g (x,y,z)
double gyro[3] = {0, 0, 0}; // deg/s (x,y,z)
double angle[3] = {0, 0, 0}; // deg (roll, pitch, yaw)
bool valid = false;
};
explicit ImuReader(const ImuConfig &cfg) : cfg_(cfg) {}
~ImuReader() { stop(); }
void start() {
running_ = true;
thread_ = std::thread(&ImuReader::run, this);
}
void stop() {
running_ = false;
if (thread_.joinable())
thread_.join();
closePort();
}
bool isConnected() {
std::lock_guard<std::mutex> lock(mutex_);
if (!port_open_ || !has_angle_)
return false;
auto now = std::chrono::steady_clock::now();
return std::chrono::duration_cast<std::chrono::milliseconds>(
now - last_rx_time_)
.count() <= cfg_.failsafe_timeout_ms;
}
Snapshot getSnapshot() {
std::lock_guard<std::mutex> lock(mutex_);
Snapshot s;
for (int i = 0; i < 3; ++i) {
s.accel[i] = accel_[i];
s.gyro[i] = gyro_[i];
s.angle[i] = angle_[i];
}
s.valid = isConnectedLocked();
return s;
}
private:
ImuConfig cfg_;
std::atomic<bool> running_{false};
std::thread thread_;
int fd_ = -1;
std::mutex mutex_;
double accel_[3] = {0, 0, 0};
double gyro_[3] = {0, 0, 0};
double angle_[3] = {0, 0, 0};
bool has_accel_ = false, has_gyro_ = false, has_angle_ = false;
std::chrono::steady_clock::time_point last_rx_time_{};
bool port_open_ = false;
static constexpr uint8_t kFrameHeader = 0x55;
static constexpr uint8_t kTypeAccel = 0x51;
static constexpr uint8_t kTypeGyro = 0x52;
static constexpr uint8_t kTypeAngle = 0x53;
bool isConnectedLocked() const {
if (!port_open_ || !has_angle_)
return false;
auto now = std::chrono::steady_clock::now();
return std::chrono::duration_cast<std::chrono::milliseconds>(
now - last_rx_time_)
.count() <= cfg_.failsafe_timeout_ms;
}
static int16_t toInt16(uint8_t lo, uint8_t hi) {
return static_cast<int16_t>(static_cast<uint16_t>(lo) |
(static_cast<uint16_t>(hi) << 8));
}
static speed_t baudToSpeed(int baud) {
switch (baud) {
case 4800:
return B4800;
case 19200:
return B19200;
case 38400:
return B38400;
case 57600:
return B57600;
case 115200:
return B115200;
case 230400:
return B230400;
default:
return B9600;
}
}
bool openPort() {
fd_ = open(cfg_.port.c_str(), O_RDWR | O_NOCTTY | O_NDELAY);
if (fd_ < 0)
return false;
fcntl(fd_, F_SETFL, 0); // 블로킹 읽기로 전환
struct termios options;
if (tcgetattr(fd_, &options) != 0) {
close(fd_);
fd_ = -1;
return false;
}
speed_t speed = baudToSpeed(cfg_.baud);
cfsetispeed(&options, speed);
cfsetospeed(&options, speed);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB;
options.c_cflag &= ~CSTOPB;
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;
options.c_cflag &= ~CRTSCTS;
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
options.c_iflag &= ~(IXON | IXOFF | IXANY);
options.c_iflag &= ~(INLCR | ICRNL);
options.c_oflag &= ~OPOST;
options.c_cc[VMIN] = 1;
options.c_cc[VTIME] = 5; // 0.5초 바이트 간 타임아웃 (종료 감지용)
tcflush(fd_, TCIFLUSH);
if (tcsetattr(fd_, TCSANOW, &options) != 0) {
close(fd_);
fd_ = -1;
return false;
}
return true;
}
void closePort() {
if (fd_ >= 0) {
close(fd_);
fd_ = -1;
}
std::lock_guard<std::mutex> lock(mutex_);
port_open_ = false;
}
// 검증된 11바이트 프레임 하나를 상태에 반영. 각도 프레임 완료 시 true.
bool applyFrame(const uint8_t *frame) {
const uint8_t type = frame[1];
switch (type) {
case kTypeAccel:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
accel_[i] = raw / 32768.0 * 16.0; // g
}
has_accel_ = true;
return false;
case kTypeGyro:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
gyro_[i] = raw / 32768.0 * 2000.0; // deg/s
}
has_gyro_ = true;
return false;
case kTypeAngle:
for (int i = 0; i < 3; ++i) {
int16_t raw = toInt16(frame[2 + 2 * i], frame[3 + 2 * i]);
angle_[i] = raw / 32768.0 * 180.0; // deg
}
has_angle_ = true;
return true;
default:
return false; // 시간/쿼터니언/GPS 등: Phase 1에서는 무시
}
}
void run() {
auto last_open_attempt =
std::chrono::steady_clock::now() - std::chrono::milliseconds(1001);
uint8_t buf[11];
size_t buf_len = 0;
while (running_) {
if (fd_ < 0) {
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(
now - last_open_attempt)
.count() > 1000) {
last_open_attempt = now;
if (openPort()) {
std::lock_guard<std::mutex> lock(mutex_);
port_open_ = true;
std::cout << "\n[정보] IMU 포트(" << cfg_.port << ") 연결 성공.\n";
} else {
std::cerr << "\n[경고] IMU 포트(" << cfg_.port
<< ") 열기 실패. 1초 후 재시도.\n";
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
buf_len = 0;
continue;
}
uint8_t byte;
ssize_t n = read(fd_, &byte, 1);
if (n <= 0) {
if (n < 0 && errno != EAGAIN && errno != EINTR) {
std::cerr << "\n[경고] IMU 포트 통신 오류. 재연결 시도...\n";
closePort();
buf_len = 0;
}
continue; // n==0: VTIME 타임아웃, running_ 재확인 후 계속 대기
}
if (buf_len == 0) {
if (byte != kFrameHeader)
continue; // 헤더 대기하며 재동기화
buf[buf_len++] = byte;
continue;
}
buf[buf_len++] = byte;
if (buf_len < 11)
continue;
uint8_t sum = 0;
for (int i = 0; i < 10; ++i)
sum += buf[i];
if (sum != buf[10]) {
buf_len = 0; // 체크섬 불일치: 프레임 폐기 후 재동기화
continue;
}
bool angle_done;
{
std::lock_guard<std::mutex> lock(mutex_);
angle_done = applyFrame(buf);
if (angle_done)
last_rx_time_ = std::chrono::steady_clock::now();
}
buf_len = 0;
(void)angle_done;
}
}
};
int main(int argc, char **argv) { int main(int argc, char **argv) {
std::signal(SIGINT, signalHandler); std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, signalHandler); std::signal(SIGTERM, signalHandler);
std::string port1 = "/dev/ttyUSB1"; // 현재 고정 배선: 모터 드라이버 = ttyUSB1 // udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된
// 심볼릭 링크. ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름은 고정.
std::string port1 = "/dev/ttyMOTOR";
std::string port2 = ""; std::string port2 = "";
uint8_t id1 = 1; uint8_t id1 = 1;
uint8_t id2 = 2; uint8_t id2 = 2;
bool bcast_mode = false; bool bcast_mode = false;
RcConfig rc_cfg; RcConfig rc_cfg;
float max_spin_v = 0.30f; // m/s float max_spin_v = 0.30f; // m/s
float track_width = 0.576f; // 좌우 바퀴 중심 거리 576mm
float wheelbase = 0.3684f; // 전후 바퀴 중심 거리 368.4mm
float wheel_radius = 0.131517f; // 사용자 실측 정밀 10.35인치 휠 반지름 float wheel_radius = 0.131517f; // 사용자 실측 정밀 10.35인치 휠 반지름
float k_skid_override = -1.0f; // 0 이상이면 Phase 5a 기본 캘리브레이션값을 대체
float effective_w_override = -1.0f; // 0 이상이면 Phase 5a 기본 캘리브레이션값을 대체
// 가속은 역기전력 보호회로가 하드웨어로 구성되어 목표값에 1:1로 즉시 // 가속은 역기전력 보호회로가 하드웨어로 구성되어 목표값에 1:1로 즉시
// 추종하므로 별도의 가속 한계값이 없다. decel_rate/jerk_rate(및 // 추종하므로 별도의 가속 한계값이 없다. decel_rate/jerk_rate(및
// spin_jerk_rate)는 감속에만 적용된다. // spin_jerk_rate)는 감속에만 적용된다.
@@ -939,6 +1225,27 @@ int main(int argc, char **argv) {
// 살짝 벗어나게 해 스크럽 마찰 저항을 줄임) // 살짝 벗어나게 해 스크럽 마찰 저항을 줄임)
float bumper_offset = 0.045f; // 바퀴 축에서 로봇 맨 앞 범퍼까지의 거리 float bumper_offset = 0.045f; // 바퀴 축에서 로봇 맨 앞 범퍼까지의 거리
// Phase 3 — 직진 헤딩 홀드 PI 트림 (doc/06-imu-integration-plan.md §Phase 3).
// 조향 중립 + 라이다 회피 미개입 + 실제 주행 중일 때만 개입해, 진입 순간의
// fused_yaw_deg를 목표로 고정하고 그로부터 벗어난 만큼을 PI로 보정한다.
bool heading_hold_enabled = true;
float heading_kp = 0.6f; // rad/s per rad 오차
float heading_ki = 0.15f; // rad/s per (rad·s) 적분 오차
float heading_max_trim = 0.15f; // rad/s (트림 상한 — 의도적 조향을 압도하지 않도록)
// Phase 4 — IMU 기반 전신(whole-body) 슬립 감지 (doc §Phase 4). 지령
// omega 대비 바퀴 피드백과 무관한 IMU 원시 gyro_z 비율이 임계값 밑으로
// 지속되면 "전신 슬립"(빙판/젖은 잔디 등 4륜 동시 헛돎)으로 판정해 라이다
// 회피의 speed_scale과 같은 패턴으로 속도를 일시적으로 낮춘다. 제자리
// 회전은 반력 토크로 인한 정상적인 스크럽 손실(지령 대비 40~60% 미달도
// 흔함)이 있어 문턱값을 낮게(=크게 미달일 때만) 잡아 정상 회전을 오탐하지
// 않게 한다.
bool whole_slip_enabled = true;
float whole_slip_min_omega = 0.15f; // rad/s (이 미만 지령은 판정 보류)
float whole_slip_ratio_threshold = 0.15f; // |imu_omega|/|omega| 가 이 밑이면 슬립 후보
int whole_slip_debounce_ticks = 15; // 틱 (100Hz 기준 150ms) 연속돼야 확정
float whole_slip_speed_scale = 0.5f; // 슬립 확정 시 v_x/omega에 곱하는 배율
// 바퀴별 이상(들뜸/걸림) 판정 파라미터: 속도 폐루프 특성상 "지령 대비 실제 // 바퀴별 이상(들뜸/걸림) 판정 파라미터: 속도 폐루프 특성상 "지령 대비 실제
// 속도" 하나만으로는 무부하(들뜸) 판정이 안 되므로, 동료 바퀴 대비 전류 // 속도" 하나만으로는 무부하(들뜸) 판정이 안 되므로, 동료 바퀴 대비 전류
// 편차와 함께 두 축으로 판정한다. // 편차와 함께 두 축으로 판정한다.
@@ -951,13 +1258,21 @@ int main(int argc, char **argv) {
// 지연이 동반되면 걸림/과부하로 판정 // 지연이 동반되면 걸림/과부하로 판정
int airborne_debounce_ticks = 5; // 틱 (100Hz 기준 50ms) 연속 들뜸 판정 int airborne_debounce_ticks = 5; // 틱 (100Hz 기준 50ms) 연속 들뜸 판정
// 시에만 실제로 목표속도를 낮춤 // 시에만 실제로 목표속도를 낮춤
std::string log_path = ""; // 비어있으면 로깅 비활성화. --log <파일경로>로 지정 // 비어있으면 프로그램 시작 시 logs/drive_YYYYmmdd_HHMMSS.csv로 자동 생성된다
// (매 주행마다 RC/IMU/모터 로깅을 자동으로 별도 파일에 남기기 위함).
// --log <파일경로>로 직접 지정하거나 --no_log로 완전히 끌 수 있다.
std::string log_path = "";
bool no_log = false;
// 실측 결과 정격15A/최대30A(공장 기본값) 그대로였음. 정격 위로 5A 여유는 // 실측 결과 정격15A/최대30A(공장 기본값) 그대로였음. 정격 위로 5A 여유는
// 남기되, 걸림/과부하 상황에서 30A까지 밀어붙이며 3초씩 버티다 과부하 // 남기되, 걸림/과부하 상황에서 30A까지 밀어붙이며 3초씩 버티다 과부하
// 알람이 터지는 걸 막기 위해 기본값을 20A로 낮춤. --max_current_a로 조정 가능. // 알람이 터지는 걸 막기 위해 기본값을 20A로 낮춤. --max_current_a로 조정 가능.
// 음수를 주면 미변경(공장/기존 설정 유지). // 음수를 주면 미변경(공장/기존 설정 유지).
float max_current_a = 20.0f; float max_current_a = 20.0f;
// IMU 파라미터 (doc/06-imu-integration-plan.md Phase 1: 순수 관측)
bool use_imu = true;
ImuConfig imu_cfg;
// LiDAR Parameters & Robot Physical Specs (User Specification) // LiDAR Parameters & Robot Physical Specs (User Specification)
bool use_lidar = true; bool use_lidar = true;
std::string lidar_config = "mid360s_config.json"; std::string lidar_config = "mid360s_config.json";
@@ -987,10 +1302,10 @@ int main(int argc, char **argv) {
id1 = static_cast<uint8_t>(std::stoi(argv[++i])); id1 = static_cast<uint8_t>(std::stoi(argv[++i]));
else if (arg == "--id2" && i + 1 < argc) else if (arg == "--id2" && i + 1 < argc)
id2 = static_cast<uint8_t>(std::stoi(argv[++i])); id2 = static_cast<uint8_t>(std::stoi(argv[++i]));
else if (arg == "--track_width" && i + 1 < argc) else if (arg == "--k_skid" && i + 1 < argc)
track_width = std::stof(argv[++i]); k_skid_override = std::stof(argv[++i]);
else if (arg == "--wheelbase" && i + 1 < argc) else if (arg == "--effective_w" && i + 1 < argc)
wheelbase = std::stof(argv[++i]); effective_w_override = std::stof(argv[++i]);
else if (arg == "--radius" && i + 1 < argc) else if (arg == "--radius" && i + 1 < argc)
wheel_radius = std::stof(argv[++i]); wheel_radius = std::stof(argv[++i]);
else if (arg == "--bumper" && i + 1 < argc) else if (arg == "--bumper" && i + 1 < argc)
@@ -1015,6 +1330,8 @@ int main(int argc, char **argv) {
airborne_debounce_ticks = std::stoi(argv[++i]); airborne_debounce_ticks = std::stoi(argv[++i]);
else if (arg == "--log" && i + 1 < argc) else if (arg == "--log" && i + 1 < argc)
log_path = argv[++i]; log_path = argv[++i];
else if (arg == "--no_log")
no_log = true;
else if (arg == "--max_current_a" && i + 1 < argc) else if (arg == "--max_current_a" && i + 1 < argc)
max_current_a = std::stof(argv[++i]); max_current_a = std::stof(argv[++i]);
else if (arg == "--bcast" || arg == "--broadcast") else if (arg == "--bcast" || arg == "--broadcast")
@@ -1041,6 +1358,12 @@ int main(int argc, char **argv) {
max_z = std::stof(argv[++i]); max_z = std::stof(argv[++i]);
else if (arg == "--no_lidar") else if (arg == "--no_lidar")
use_lidar = false; use_lidar = false;
else if (arg == "--imu_port" && i + 1 < argc)
imu_cfg.port = argv[++i];
else if (arg == "--imu_baud" && i + 1 < argc)
imu_cfg.baud = std::stoi(argv[++i]);
else if (arg == "--no_imu")
use_imu = false;
else if (arg == "--rc_port" && i + 1 < argc) else if (arg == "--rc_port" && i + 1 < argc)
rc_cfg.port = argv[++i]; rc_cfg.port = argv[++i];
else if (arg == "--rc_baud" && i + 1 < argc) else if (arg == "--rc_baud" && i + 1 < argc)
@@ -1085,15 +1408,55 @@ int main(int argc, char **argv) {
rc_cfg.failsafe_timeout_ms = std::stoi(argv[++i]); rc_cfg.failsafe_timeout_ms = std::stoi(argv[++i]);
else if (arg == "--max_spin_v" && i + 1 < argc) else if (arg == "--max_spin_v" && i + 1 < argc)
max_spin_v = std::stof(argv[++i]); max_spin_v = std::stof(argv[++i]);
else if (arg == "--no_heading_hold")
heading_hold_enabled = false;
else if (arg == "--heading_kp" && i + 1 < argc)
heading_kp = std::stof(argv[++i]);
else if (arg == "--heading_ki" && i + 1 < argc)
heading_ki = std::stof(argv[++i]);
else if (arg == "--heading_max_trim" && i + 1 < argc)
heading_max_trim = std::stof(argv[++i]);
else if (arg == "--no_whole_slip")
whole_slip_enabled = false;
else if (arg == "--whole_slip_min_omega" && i + 1 < argc)
whole_slip_min_omega = std::stof(argv[++i]);
else if (arg == "--whole_slip_ratio" && i + 1 < argc)
whole_slip_ratio_threshold = std::stof(argv[++i]);
else if (arg == "--whole_slip_debounce_ticks" && i + 1 < argc)
whole_slip_debounce_ticks = std::stoi(argv[++i]);
else if (arg == "--whole_slip_speed_scale" && i + 1 < argc)
whole_slip_speed_scale = std::stof(argv[++i]);
}
// --log를 직접 지정하지 않았으면 이 저장소(프로젝트 폴더) 안의 logs/에
// 타임스탬프 파일명으로 자동 생성한다 — 매 주행마다 RC/IMU/모터 로깅을
// 빠짐없이 남기기 위함. --no_log로 완전히 끌 수 있다.
if (log_path.empty() && !no_log) {
std::filesystem::create_directories("logs");
std::time_t now_c = std::time(nullptr);
char ts_buf[32];
std::strftime(ts_buf, sizeof(ts_buf), "%Y%m%d_%H%M%S", std::localtime(&now_c));
log_path = std::string("logs/drive_") + ts_buf + ".csv";
} }
constexpr float PI_VAL = 3.14159265358979323846f; constexpr float PI_VAL = 3.14159265358979323846f;
float rpm_per_ms = 60.0f / (2.0f * PI_VAL * wheel_radius); float rpm_per_ms = 60.0f / (2.0f * PI_VAL * wheel_radius);
// 4WD 유효 선회 계수 // effective_w — Phase 5a 정적 캘리브레이션. 기하학적 공식값(대각선 길이,
float effective_w = // 0.684)은 제자리 회전 실측 대비 작았다. 전용 회전 로그 2개(무결점
std::sqrt(track_width * track_width + wheelbase * wheelbase); // 405틱, 두 로그 개별 중앙값 0.855/0.904로 서로 일치)에서 역산한
float k_skid = (track_width + (wheelbase * wheelbase) / track_width) / 2.0f; // 중앙값 0.87을 대신 사용한다. --effective_w로 재조정 가능.
float effective_w = (effective_w_override >= 0.0f) ? effective_w_override : 0.87f;
// k_skid — Phase 5a 정적 캘리브레이션(doc/06-imu-integration-plan.md §5a):
// 기하학적 공식값(트랙폭/휠베이스로만 계산, 0.406)은 실측 대비 계속
// 작게 나왔다(회전이 지령보다 항상 덜 도는 원인 중 하나). 두 차례
// 실주행 로그(커브 선회 중 무결점 총 5,150틱)에서 바퀴 실측 속도차 대
// IMU 실측 회전율로 역산한 중앙값이 0.51 적용 전 0.510, 적용 후에도
// 0.517로 수렴해 그대로 0.51을 유지한다. 회전 반경에 따라
// 0.44(완만한 커브)~0.53(급한 커브)로 흔들리는 편이라 0.51은 그
// 절충값 — 반경별로 정밀하게 맞추려면 Phase 5b(온라인 ICR 추정)가
// 필요하다. --k_skid로 재조정 가능.
float k_skid = (k_skid_override >= 0.0f) ? k_skid_override : 0.51f;
std::cout << "\n=============================================================" std::cout << "\n============================================================="
"=====\n"; "=====\n";
@@ -1129,20 +1492,27 @@ int main(int argc, char **argv) {
std::cout std::cout
<< "==================================================================\n"; << "==================================================================\n";
// CSV 로깅: --log <경로> 지정 시 매 틱 지령/피드백/전류/휠상태를 파일로 기록. // CSV 로깅: 기본적으로 매 주행마다 logs/에 자동 저장된다(위 auto-path 로직).
// 실외에서 로봇을 조종하며 화면을 동시에 읽기 어려우므로, 문제가 된 // RC 채널, IMU(자이로/각도), 모터 지령/피드백/전류/휠상태/엔코더 raw tick까지
// 구간(턱 넘는 지점 등)을 나중에 잘라서 분석하기 위한 용도. // 매 틱 전부 기록한다. 실외에서 로봇을 조종하며 화면을 동시에 읽기 어려우므로,
// 문제가 된 구간(턱 넘는 지점 등)이나 IMU-엔코더 융합용 데이터를 나중에 잘라서
// 분석하기 위한 용도.
std::ofstream log_stream; std::ofstream log_stream;
if (!log_path.empty()) { if (!log_path.empty()) {
log_stream.open(log_path); log_stream.open(log_path);
if (log_stream.is_open()) { if (log_stream.is_open()) {
log_stream << "t_ms,state,rc_steer,rc_throttle,rc_ok,brake,vmax,vx,omega," log_stream << "t_ms,state,rc_steer,rc_throttle,rc_ok,brake,vmax,vx,omega,"
"ch1,ch2,ch3,ch4,ch5,ch6,ch7,ch8," "ch1,ch2,ch3,ch4,ch5,ch6,ch7,ch8,"
"imu_ok,imu_gyro_z,imu_roll,imu_pitch,imu_yaw,imu_yaw_rel,"
"cmd_fl,cmd_fr,cmd_rl,cmd_rr," "cmd_fl,cmd_fr,cmd_rl,cmd_rr,"
"fb_fl,fb_fr,fb_rl,fb_rr," "fb_fl,fb_fr,fb_rl,fb_rr,"
"amp_fl,amp_fr,amp_rl,amp_rr," "amp_fl,amp_fr,amp_rl,amp_rr,"
"temp_fl,temp_fr,temp_rl,temp_rr,temp_drv_front,temp_drv_rear,"
"stat_fl,stat_fr,stat_rl,stat_rr," "stat_fl,stat_fr,stat_rl,stat_rr,"
"err_fl,err_fr,err_rl,err_rr," "err_fl,err_fr,err_rl,err_rr,"
"tick_fl,tick_fr,tick_rl,tick_rr,"
"wheel_omega,imu_omega,omega_residual,fused_yaw,odom_x,odom_y,"
"heading_hold,heading_target,heading_trim,whole_slip,"
"dist_axle,comm_ms\n"; "dist_axle,comm_ms\n";
std::cout << " - CSV 로그 기록: " << log_path << "\n"; std::cout << " - CSV 로그 기록: " << log_path << "\n";
} else { } else {
@@ -1174,6 +1544,11 @@ int main(int argc, char **argv) {
"동시에 읽으면 RC 데이터 파싱이 깨질 수 있으니 서로 다른 " "동시에 읽으면 RC 데이터 파싱이 깨질 수 있으니 서로 다른 "
"포트로 지정하세요.\n\n"; "포트로 지정하세요.\n\n";
} }
if (use_imu && (imu_cfg.port == port1 || imu_cfg.port == rc_cfg.port)) {
std::cout << "\n[경고] --imu_port(" << imu_cfg.port
<< ")가 모터 또는 RC 수신기 포트와 동일합니다. 서로 다른 "
"포트로 지정하세요.\n\n";
}
RcReceiver rc(rc_cfg); RcReceiver rc(rc_cfg);
rc.start(); rc.start();
@@ -1181,6 +1556,14 @@ int main(int argc, char **argv) {
<< rc_cfg.port << "). 신호 수신 전까지는 안전을 위해 브레이크가 " << rc_cfg.port << "). 신호 수신 전까지는 안전을 위해 브레이크가 "
"걸린 상태로 대기합니다.\n"; "걸린 상태로 대기합니다.\n";
ImuReader imu(imu_cfg);
if (use_imu) {
imu.start();
std::cout << "[정보] IMU(HWT905-RS232) 리더 시작 (" << imu_cfg.port << " @"
<< imu_cfg.baud << "bps). Phase 1: 관측 전용, 제어에는 개입하지 "
"않습니다.\n";
}
SerialPort sp1, sp2; SerialPort sp1, sp2;
if (!sp1.openPort(port1)) if (!sp1.openPort(port1))
return 1; return 1;
@@ -1268,7 +1651,16 @@ int main(int argc, char **argv) {
return target_vel; return target_vel;
} }
bool is_accelerating = std::abs(target_vel) > std::abs(current_vel); // 같은 방향으로 더 빨라지는 경우만 즉시 스냅한다. 부호가 바뀌는 역전은
// (현재속도가 0이 아닌데 목표가 반대부호로 바뀌는 경우) 저크 제한 감속
// 경로로 보낸다 — 정지 직전 저속 구간에서 RC/조향 노이즈만으로 목표가
// 순간 반대부호로 흔들려도 그대로 즉시 스냅되면 "전진→후진→전진"처럼
// 튀는 현상이 생기고, 실제 반전 요청이라도 0을 관통하는 순간 스냅은
// 역기전력 스파이크가 오히려 일반 가속보다 더 크다.
bool same_direction =
(current_vel == 0.0f) || (target_vel * current_vel > 0.0f);
bool is_accelerating =
same_direction && (std::abs(target_vel) > std::abs(current_vel));
if (is_accelerating) { if (is_accelerating) {
current_accel = 0.0f; current_accel = 0.0f;
return target_vel; return target_vel;
@@ -1307,6 +1699,14 @@ int main(int argc, char **argv) {
bool just_landed_rl = false, just_landed_rr = false; bool just_landed_rl = false, just_landed_rr = false;
char wheel_status_str[5] = "OOOO"; char wheel_status_str[5] = "OOOO";
bool fault_active = false; // 드라이버 알람(과전류/과부하 등) 발생 여부 bool fault_active = false; // 드라이버 알람(과전류/과부하 등) 발생 여부
// 드라이버 온도(0x20B0)는 열 시정수가 초 단위로 느려 매 틱 통신할
// 필요가 없다 — 30틱(실측 ~27Hz 기준 약 1.1초)마다만 갱신.
float front_driver_temp_c = 0.0f, rear_driver_temp_c = 0.0f;
int driver_temp_poll_counter = 0;
constexpr int kDriverTempPollTicks = 30;
// 하드웨어 스펙(doc/01) 동작온도 상한 50°C, 드라이버 자체 과열 보호
// 임계값 기본 80°C 대비 여유를 두고 조기 경보를 띄우는 기준.
constexpr int kOverheatWarnC = 60;
bool prev_brake_on = false; // 브레이크(CH5/Fail-safe) 직전 틱 상태 bool prev_brake_on = false; // 브레이크(CH5/Fail-safe) 직전 틱 상태
bool prev_rc_ok = false; // RC 연결 직전 틱 상태 (Fail-safe 로그용) bool prev_rc_ok = false; // RC 연결 직전 틱 상태 (Fail-safe 로그용)
float omega_max = (effective_w > 0.0f) ? (max_spin_v / (effective_w / 2.0f)) float omega_max = (effective_w > 0.0f) ? (max_spin_v / (effective_w / 2.0f))
@@ -1329,6 +1729,40 @@ int main(int argc, char **argv) {
bool steer_initialized = false, throttle_initialized = false; bool steer_initialized = false, throttle_initialized = false;
int steer_reject_streak = 0, throttle_reject_streak = 0; int steer_reject_streak = 0, throttle_reject_streak = 0;
// IMU Phase 1: 순수 관측용 표시 오프셋. 로봇 전원이 켜질 때(또는 이
// 프로그램이 시작될 때) IMU가 처음 보고하는 절대 yaw를 "정면(0°)"
// 기준으로 저장해, 화면에는 그 시점 대비 상대 yaw를 보여준다. 이건
// 순수 표시용 계산일 뿐 제어 로직에는 전혀 관여하지 않는다 — 헤딩을
// 실제로 잠그거나 보정하는 건 Phase 3의 몫이다.
double imu_yaw_offset = 0.0;
bool imu_yaw_offset_set = false;
// Phase 2 — 헤딩 적분 + x,y 오도메트리 (여전히 순수 관측, 제어 미개입).
// 처음엔 IMU 내장 AHRS가 계산해 주는 imu_yaw_rel을 θ로 그대로 썼으나,
// 실측 루프백(한 바퀴 돌아 제자리 복귀) 주행 로그로 검증한 결과 그 값이
// 같은 장치의 원시 자이로(gyro_z)와도 1초 평균 구간 기준 부호 일치율이
// 72%에 불과함이 드러났다(회전 중 모터 전류로 인한 지자기 간섭 등으로
// 추정 — 저가 AHRS의 흔한 증상). 실제로 그 루프백 로그에서 odom이 시작
// 위치로 전혀 돌아오지 못했다. 반면 바퀴 인코더 기반 wheel_omega와 원시
// gyro_z는 부호 일치율 99.3%로 서로 강하게 신뢰할 수 있음을 같은 로그에서
// 확인했다. 그래서 θ는 IMU 융합 yaw 대신 wheel_omega와 원시 gyro_z의
// 평균을 자체 적분한 fused_yaw_deg를 쓴다(IMU 미연결 시엔 wheel_omega만
// 사용). IMU 융합 yaw(imu_yaw_rel)는 비교용으로 HUD/CSV에 계속 남긴다.
double odom_x = 0.0, odom_y = 0.0; // m, Phase 1 오프셋 설정 시점 기준
double fused_yaw_deg = 0.0; // deg, wheel_omega+gyro_z 자체 적분 헤딩
auto last_odom_time = std::chrono::steady_clock::now();
// Phase 3 — 헤딩 홀드 PI 상태. 적분 windup 방지용 상한은 트림 상한을
// Ki로 나눠 "적분항 단독으로도 트림 상한을 넘지 않는" 값으로 고정한다.
bool heading_hold_active_prev = false;
double heading_target_deg = 0.0;
double heading_error_integral = 0.0;
const float heading_integral_limit =
(heading_ki > 1e-6f) ? (heading_max_trim / heading_ki) : 0.0f;
// Phase 4 — 전신 슬립 디바운스 카운터
int whole_slip_count = 0;
while (g_running) { while (g_running) {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// RC 수신기 채널 읽기 (doc/joystick.md §2, §3 기능 명세서 기준) // RC 수신기 채널 읽기 (doc/joystick.md §2, §3 기능 명세서 기준)
@@ -1388,6 +1822,25 @@ int main(int argc, char **argv) {
raw_ch[ch] = 0; // 아직 수신 못한 채널은 0으로 표시 raw_ch[ch] = 0; // 아직 수신 못한 채널은 0으로 표시
} }
// IMU Phase 1: 순수 관측. 제어 로직에는 관여하지 않는다.
ImuReader::Snapshot imu_snap = use_imu ? imu.getSnapshot() : ImuReader::Snapshot{};
if (imu_snap.valid && !imu_yaw_offset_set) {
imu_yaw_offset = imu_snap.angle[2];
imu_yaw_offset_set = true;
std::cout << "\n[정보] IMU 정면 기준 설정: 시작 시점 yaw " << imu_yaw_offset
<< "도를 0도(정면)로 저장했습니다.\n";
}
double imu_yaw_rel = 0.0;
if (imu_yaw_offset_set) {
imu_yaw_rel = imu_snap.angle[2] - imu_yaw_offset;
// [-180, 180) 범위로 정규화
while (imu_yaw_rel > 180.0) imu_yaw_rel -= 360.0;
while (imu_yaw_rel < -180.0) imu_yaw_rel += 360.0;
}
// 바퀴 피드백과 무관한 IMU 원시 요레이트. Phase 2(휠-IMU 융합 헤딩)와
// Phase 4(지령-IMU 전신 슬립 감지, 명령 결정 이전에 필요)가 공유한다.
float imu_omega_rad = imu_snap.gyro[2] * (PI_VAL / 180.0f); // rad/s
if (!rc_ok && prev_rc_ok) { if (!rc_ok && prev_rc_ok) {
std::cout << "\n[경고] RC 신호 Fail-safe 발동 (" << rc_cfg.failsafe_timeout_ms std::cout << "\n[경고] RC 신호 Fail-safe 발동 (" << rc_cfg.failsafe_timeout_ms
<< "ms 이상 미수신) -> 비상 정지.\n"; << "ms 이상 미수신) -> 비상 정지.\n";
@@ -1404,6 +1857,7 @@ int main(int argc, char **argv) {
float v_x = 0.0f; // 선속도 m/s float v_x = 0.0f; // 선속도 m/s
float omega = 0.0f; // 각속도 rad/s float omega = 0.0f; // 각속도 rad/s
bool user_steer_centered = false; // Phase 3 헤딩 홀드 진입 조건용
if (!brake_on) { if (!brake_on) {
// 3.3 전후진 속도 계산 (CH2) — 실측 결과 raw 값이 높을수록 전진(+), // 3.3 전후진 속도 계산 (CH2) — 실측 결과 raw 값이 높을수록 전진(+),
@@ -1438,6 +1892,7 @@ int main(int argc, char **argv) {
static_cast<float>(rc_cfg.steer_max - rc_cfg.steer_center_hi)) * static_cast<float>(rc_cfg.steer_max - rc_cfg.steer_center_hi)) *
omega_max; omega_max;
} }
user_steer_centered = (omega == 0.0f);
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -1445,11 +1900,12 @@ int main(int argc, char **argv) {
// (요청에 따라 후진 시 측면 회피는 미적용 - 전진 시에만 좌우 회피 동작) // (요청에 따라 후진 시 측면 회피는 미적용 - 전진 시에만 좌우 회피 동작)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
std::string lidar_telemetry = "LIDAR:OFF"; std::string lidar_telemetry = "LIDAR:OFF";
float avoid_omega_offset = 0.0f; // Phase 3 헤딩 홀드가 라이다 회피와
// 충돌하지 않도록 블록 밖에서도 확인
if (use_lidar) { if (use_lidar) {
ObstacleStatus obs = lidar_detector.getStatus(); ObstacleStatus obs = lidar_detector.getStatus();
if (obs.connected) { if (obs.connected) {
float speed_scale = 1.0f; float speed_scale = 1.0f;
float avoid_omega_offset = 0.0f;
// 1. Forward / Backward Automatic Stopping // 1. Forward / Backward Automatic Stopping
if (v_x > 0.01f) { if (v_x > 0.01f) {
@@ -1509,6 +1965,68 @@ int main(int argc, char **argv) {
} }
} }
// ---------------------------------------------------------------------
// Phase 3 — 직진 헤딩 홀드 (doc/06-imu-integration-plan.md §Phase 3).
// 조향 중립 + 라이다 회피 미개입 + 실제 주행 중일 때만 개입한다. 진입
// 순간의 fused_yaw_deg를 목표 헤딩으로 고정(lock)하고, 이후 그로부터
// 벗어난 만큼(오차)을 작은 PI로 보정해 omega에 트림을 더한다. 사용자
// 조향/정지/라이다 회피가 개입하는 즉시 해제되고 적분항도 리셋된다.
// Phase 2 검증(실측 루프백 로그)에서 wheel_omega/gyro_z 부호 일치율이
// 99.3%로 확인된 fused_yaw_deg를 피드백으로 쓴다 — IMU 단독 융합 yaw는
// 회전 중 신뢰도가 낮아 제어 피드백에서 제외했다(imu_yaw_rel은 HUD/CSV
// 비교용으로만 남김).
bool heading_hold_condition = heading_hold_enabled && !brake_on &&
user_steer_centered &&
avoid_omega_offset == 0.0f && v_x != 0.0f;
float heading_trim_omega = 0.0f; // HUD/CSV 노출용
if (heading_hold_condition) {
if (!heading_hold_active_prev) {
heading_target_deg = fused_yaw_deg;
heading_error_integral = 0.0;
}
double heading_error_deg = heading_target_deg - fused_yaw_deg;
while (heading_error_deg > 180.0) heading_error_deg -= 360.0;
while (heading_error_deg < -180.0) heading_error_deg += 360.0;
double heading_error_rad = heading_error_deg * (PI_VAL / 180.0);
heading_error_integral += heading_error_rad * dt;
heading_error_integral = std::clamp(
heading_error_integral,
static_cast<double>(-heading_integral_limit),
static_cast<double>(heading_integral_limit));
heading_trim_omega = static_cast<float>(
heading_kp * heading_error_rad + heading_ki * heading_error_integral);
heading_trim_omega =
std::clamp(heading_trim_omega, -heading_max_trim, heading_max_trim);
omega += heading_trim_omega;
} else {
heading_error_integral = 0.0;
}
heading_hold_active_prev = heading_hold_condition;
// ---------------------------------------------------------------------
// Phase 4 — IMU 기반 전신(whole-body) 슬립 감지 (doc §Phase 4). 지령
// omega(라이다 회피/헤딩 홀드 트림까지 반영된 최종값) 대비 바퀴 피드백과
// 완전히 독립적인 IMU 원시 gyro_z의 비율이 문턱값 밑으로
// whole_slip_debounce_ticks 틱 이상 지속되면 "전신 슬립"으로 확정하고,
// 라이다 회피의 speed_scale과 같은 패턴으로 v_x/omega를 일시적으로
// 낮춘다. 문턱값을 낮게(0.15) 잡아 제자리 회전의 정상적인 스크럽 손실
// (지령 대비 40~60% 미달도 흔함)과 혼동하지 않고 사실상 전혀 못 도는
// 극단적 슬립만 잡아낸다.
bool whole_slip_now = false;
if (whole_slip_enabled && imu_snap.valid &&
std::abs(omega) > whole_slip_min_omega) {
float slip_ratio = std::abs(imu_omega_rad) / std::abs(omega);
whole_slip_now = slip_ratio < whole_slip_ratio_threshold;
}
whole_slip_count = whole_slip_now ? whole_slip_count + 1 : 0;
bool whole_slip_active = whole_slip_count >= whole_slip_debounce_ticks;
if (whole_slip_active) {
v_x *= whole_slip_speed_scale;
omega *= whole_slip_speed_scale;
}
// 제자리 회전 여부: CH2(전후진) 입력이 완전히 중립(v_x == 0.0f)이면서 // 제자리 회전 여부: CH2(전후진) 입력이 완전히 중립(v_x == 0.0f)이면서
// CH1(조향) 입력이 있을 때만 진입한다. v_x는 데드존 처리 시 정확히 // CH1(조향) 입력이 있을 때만 진입한다. v_x는 데드존 처리 시 정확히
// 0.0f로 설정되므로 등호 비교가 안전하다. // 0.0f로 설정되므로 등호 비교가 안전하다.
@@ -1541,6 +2059,13 @@ int main(int argc, char **argv) {
// 직전 틱 피드백에서 무부하(들뜸)로 판정된 바퀴는 목표 속도를 0으로 낮춰 // 직전 틱 피드백에서 무부하(들뜸)로 판정된 바퀴는 목표 속도를 0으로 낮춰
// 헛돌이를 억제한다. 접지력이 회복되어 전류가 정상으로 돌아오면 다음 // 헛돌이를 억제한다. 접지력이 회복되어 전류가 정상으로 돌아오면 다음
// 판정 틱에서 자동으로 해제되어 원래 지령으로 복귀한다. // 판정 틱에서 자동으로 해제되어 원래 지령으로 복귀한다.
// 단, 제자리 회전 중에는 반력 토크로 인한 대각선 하중 이동(FL+RR 또는
// FR+RL 쌍이 동시에 가벼워짐)이 정상적인 현상인데 이걸 "들뜸"으로 오判定해
// 목표 속도를 꺼버리면 4륜 중 사실상 2륜만 구동되어 회전력이 반토막
// 난다(실측 로그에서 전체 회전 틱의 38%가 대각선 쌍 동시 들뜸 판정이었고,
// 그 결과 실제 회전율이 지령 대비 44~58%에 그쳤음). 그래서 제자리 회전
// 중에는 판정(HUD 표시용)은 유지하되 속도를 꺾는 개입만 끈다.
if (!is_spin_turn) {
if (airborne_fl) if (airborne_fl)
target_fl = 0.0f; target_fl = 0.0f;
if (airborne_fr) if (airborne_fr)
@@ -1549,6 +2074,7 @@ int main(int argc, char **argv) {
target_rl = 0.0f; target_rl = 0.0f;
if (airborne_rr) if (airborne_rr)
target_rr = 0.0f; target_rr = 0.0f;
}
// 3.1 브레이크 최우선 로직 (CH5/Fail-safe): 저크 램프를 건너뛰고 즉시 // 3.1 브레이크 최우선 로직 (CH5/Fail-safe): 저크 램프를 건너뛰고 즉시
// 0으로 스냅 후 브레이크를 잠근다. 해제되면 STOPPED 상태에서 아래 // 0으로 스냅 후 브레이크를 잠근다. 해제되면 STOPPED 상태에서 아래
@@ -1647,12 +2173,21 @@ int main(int argc, char **argv) {
float fl_amp = 0, fr_amp = 0, rl_amp = 0, rr_amp = 0; float fl_amp = 0, fr_amp = 0, rl_amp = 0, rr_amp = 0;
int32_t fl_tick = 0, fr_tick = 0, rl_tick = 0, rr_tick = 0; int32_t fl_tick = 0, fr_tick = 0, rl_tick = 0, rr_tick = 0;
uint16_t err_f_l = 0, err_f_r = 0, err_r_l = 0, err_r_r = 0; uint16_t err_f_l = 0, err_f_r = 0, err_r_l = 0, err_r_r = 0;
int fl_temp = 0, fr_temp = 0, rl_temp = 0, rr_temp = 0;
driver_front.readFeedback(fl_fb, fr_fb, fl_tick, fr_tick, fl_amp, fr_amp, driver_front.readFeedback(fl_fb, fr_fb, fl_tick, fr_tick, fl_amp, fr_amp,
err_f_l, err_f_r); err_f_l, err_f_r, fl_temp, fr_temp);
if (driver_rear_ptr) if (driver_rear_ptr)
driver_rear_ptr->readFeedback(rl_fb, rr_fb, rl_tick, rr_tick, rl_amp, driver_rear_ptr->readFeedback(rl_fb, rr_fb, rl_tick, rr_tick, rl_amp,
rr_amp, err_r_l, err_r_r); rr_amp, err_r_l, err_r_r, rl_temp, rr_temp);
// 드라이버(기판) 온도는 저빈도로만 갱신(위 kDriverTempPollTicks 주석 참고).
if (++driver_temp_poll_counter >= kDriverTempPollTicks) {
driver_temp_poll_counter = 0;
driver_front.readDriverTemp(front_driver_temp_c);
if (driver_rear_ptr)
driver_rear_ptr->readDriverTemp(rear_driver_temp_c);
}
// 드라이버 알람(과전류/과부하 등) 감지: 새로 발생한 알람만 콘솔에 // 드라이버 알람(과전류/과부하 등) 감지: 새로 발생한 알람만 콘솔에
// 한 번 크게 출력한다 (매 틱 갱신되는 HUD 줄과 별개의 고정 줄). // 한 번 크게 출력한다 (매 틱 갱신되는 HUD 줄과 별개의 고정 줄).
@@ -1776,6 +2311,40 @@ int main(int argc, char **argv) {
float dist_bumper = dist_axle + (dist_axle > 0.001f ? bumper_offset : 0.0f); float dist_bumper = dist_axle + (dist_axle > 0.001f ? bumper_offset : 0.0f);
(void)dist_bumper; (void)dist_bumper;
// Phase 2 — 휠 기반 ω(엔코더 피드백) vs IMU 기반 ω(자이로) 비교 +
// x,y 오도메트리 적분. 실측 검증(실기 로그) 결과 부호 규약이 일치함:
// 우회전(+omega)일 때 wheel_omega/imu_gyro_z 둘 다 양수로 나온다.
// 이 둘의 차이(잔차)가 §6.3-4의 슬립 지표다. 여전히 제어에는 관여하지
// 않는다 — HUD/CSV 노출까지만.
auto odom_now = std::chrono::steady_clock::now();
float odom_dt = std::chrono::duration<float>(odom_now - last_odom_time).count();
last_odom_time = odom_now;
float v_l_meas = ((fl_fb + rl_fb) / 2.0f) / rpm_per_ms; // m/s
float v_r_meas = -((fr_fb + rr_fb) / 2.0f) / rpm_per_ms; // m/s (부호 규약: fr/rr는 -v_r로 지령됨)
float v_x_meas = (v_l_meas + v_r_meas) / 2.0f;
float wheel_omega = (k_skid > 0.0f)
? (v_r_meas - v_l_meas) / (2.0f * k_skid)
: 0.0f; // rad/s
float omega_residual = wheel_omega - imu_omega_rad;
// θ 적분원: wheel_omega와 원시 gyro_z의 평균(둘의 부호/크기 일치율이
// 실측상 99.3%로 높음). IMU가 끊겼을 땐 wheel_omega만으로 대체한다.
float fused_omega_rad =
imu_snap.valid ? (wheel_omega + imu_omega_rad) / 2.0f : wheel_omega;
if (imu_yaw_offset_set && odom_dt > 0.0f && odom_dt < 0.5f) {
// odom_dt>=0.5s: Fail-safe 재연결 등으로 틱이 크게 벌어진 비정상
// 구간은 위치 적분에서 제외해 순간 도약을 막는다.
fused_yaw_deg += fused_omega_rad * (180.0 / PI_VAL) * odom_dt;
while (fused_yaw_deg > 180.0) fused_yaw_deg -= 360.0;
while (fused_yaw_deg < -180.0) fused_yaw_deg += 360.0;
double theta_rad = fused_yaw_deg * (PI_VAL / 180.0);
odom_x += v_x_meas * odom_dt * std::cos(theta_rad);
odom_y += v_x_meas * odom_dt * std::sin(theta_rad);
}
if (log_stream.is_open()) { if (log_stream.is_open()) {
float t_ms = std::chrono::duration<float, std::milli>( float t_ms = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - log_start_time) std::chrono::steady_clock::now() - log_start_time)
@@ -1786,13 +2355,26 @@ int main(int argc, char **argv) {
<< omega << ',' << raw_ch[1] << ',' << raw_ch[2] << ',' << omega << ',' << raw_ch[1] << ',' << raw_ch[2] << ','
<< raw_ch[3] << ',' << raw_ch[4] << ',' << raw_ch[5] << ',' << raw_ch[3] << ',' << raw_ch[4] << ',' << raw_ch[5] << ','
<< raw_ch[6] << ',' << raw_ch[7] << ',' << raw_ch[8] << ',' << raw_ch[6] << ',' << raw_ch[7] << ',' << raw_ch[8] << ','
<< (imu_snap.valid ? 1 : 0) << ',' << imu_snap.gyro[2] << ','
<< imu_snap.angle[0] << ',' << imu_snap.angle[1] << ','
<< imu_snap.angle[2] << ',' << imu_yaw_rel << ','
<< cmd_fl << ',' << cmd_fr << ',' << cmd_rl << cmd_fl << ',' << cmd_fr << ',' << cmd_rl
<< ',' << cmd_rr << ',' << fl_fb << ',' << fr_fb << ',' << ',' << cmd_rr << ',' << fl_fb << ',' << fr_fb << ','
<< rl_fb << ',' << rr_fb << ',' << fl_amp << ',' << fr_amp << rl_fb << ',' << rr_fb << ',' << fl_amp << ',' << fr_amp
<< ',' << rl_amp << ',' << rr_amp << ',' << wheel_status_str[0] << ',' << rl_amp << ',' << rr_amp
<< ',' << fl_temp << ',' << fr_temp << ',' << rl_temp << ','
<< rr_temp << ',' << front_driver_temp_c << ','
<< rear_driver_temp_c << ',' << wheel_status_str[0]
<< ',' << wheel_status_str[1] << ',' << wheel_status_str[2] << ',' << wheel_status_str[1] << ',' << wheel_status_str[2]
<< ',' << wheel_status_str[3] << ',' << err_f_l << ',' << ',' << wheel_status_str[3] << ',' << err_f_l << ','
<< err_f_r << ',' << err_r_l << ',' << err_r_r << ',' << err_f_r << ',' << err_r_l << ',' << err_r_r << ','
<< fl_tick << ',' << fr_tick << ',' << rl_tick << ',' << rr_tick
<< ',' << wheel_omega << ',' << imu_omega_rad << ','
<< omega_residual << ',' << fused_yaw_deg << ','
<< odom_x << ',' << odom_y << ','
<< (heading_hold_condition ? 1 : 0) << ',' << heading_target_deg
<< ',' << heading_trim_omega << ','
<< (whole_slip_active ? 1 : 0) << ','
<< dist_axle << ',' << comm_ms << '\n'; << dist_axle << ',' << comm_ms << '\n';
} }
@@ -1803,15 +2385,30 @@ int main(int argc, char **argv) {
(v_max_now <= rc_cfg.v_max_low + 0.001f) ? "LOW " : "HIGH"; (v_max_now <= rc_cfg.v_max_low + 0.001f) ? "LOW " : "HIGH";
printf("\r\033[K[%-8s] RC:%s%s " printf("\r\033[K[%-8s] RC:%s%s "
"C1:%4d C2:%4d C3:%4d C4:%4d C5:%4d C6:%4d C7:%4d C8:%4d " "C1:%4d C2:%4d C3:%4d C4:%4d C5:%4d C6:%4d C7:%4d C8:%4d "
"SPD:%s VX:%+4.2f OMG:%+4.2f | AXLE:%5.3fm | %-28s | " "SPD:%s VX:%+4.2f OMG:%+4.2f | IMU:%s GZ:%+6.2f YAW(imu):%+6.1f | "
"WO:%+5.2f IO:%+5.2f YAW(fus):%+6.1f ODO:(%+5.2f,%+5.2f) | "
"HH:%s TRG:%+6.1f TRM:%+5.2f%s | "
"AXLE:%5.3fm | %-28s | "
"I(A):FL%+4.1f FR%+4.1f RL%+4.1f RR%+4.1f | " "I(A):FL%+4.1f FR%+4.1f RL%+4.1f RR%+4.1f | "
"T(C):FL%3d FR%3d RL%3d RR%3d DRV%4.1f/%4.1f%s | "
"WHL(FL/FR/RL/RR):%s%s | %4.1fms", "WHL(FL/FR/RL/RR):%s%s | %4.1fms",
state.c_str(), rc_ok ? "OK" : "LOST", brake_on ? "[BRK]" : " ", state.c_str(), rc_ok ? "OK" : "LOST", brake_on ? "[BRK]" : " ",
raw_ch[1], raw_ch[2], raw_ch[3], raw_ch[4], raw_ch[5], raw_ch[6], raw_ch[1], raw_ch[2], raw_ch[3], raw_ch[4], raw_ch[5], raw_ch[6],
raw_ch[7], raw_ch[8], speed_mode_str, v_x, omega, raw_ch[7], raw_ch[8], speed_mode_str, v_x, omega,
imu_snap.valid ? "OK " : "LOST", imu_snap.gyro[2], imu_yaw_rel,
wheel_omega, imu_omega_rad, fused_yaw_deg, odom_x, odom_y,
heading_hold_condition ? "ON " : "off", heading_target_deg,
heading_trim_omega, whole_slip_active ? " [!!전신슬립!!]" : "",
dist_axle, lidar_telemetry.c_str(), fl_amp, fr_amp, dist_axle, lidar_telemetry.c_str(), fl_amp, fr_amp,
rl_amp, rr_amp, wheel_status_str, rl_amp, rr_amp, fl_temp, fr_temp, rl_temp, rr_temp,
fault_active ? " [!!알람!!]" : "", comm_ms); front_driver_temp_c, rear_driver_temp_c,
(fl_temp >= kOverheatWarnC || fr_temp >= kOverheatWarnC ||
rl_temp >= kOverheatWarnC || rr_temp >= kOverheatWarnC ||
front_driver_temp_c >= kOverheatWarnC ||
rear_driver_temp_c >= kOverheatWarnC)
? " [!!고온!!]"
: "",
wheel_status_str, fault_active ? " [!!알람!!]" : "", comm_ms);
fflush(stdout); fflush(stdout);
std::this_thread::sleep_for( std::this_thread::sleep_for(
@@ -1837,6 +2434,8 @@ int main(int argc, char **argv) {
} }
rc.stop(); rc.stop();
if (use_imu)
imu.stop();
std::cout << "[정보] C++ 4WD 제어 프로그램이 성공적으로 종료되었습니다.\n"; std::cout << "[정보] C++ 4WD 제어 프로그램이 성공적으로 종료되었습니다.\n";
return 0; return 0;
Binary file not shown.