Files
fori_zltech_motor_test/xbox_motor_control.cpp
T
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

2442 lines
103 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <algorithm>
#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <csignal>
#include <ctime>
#include <fcntl.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <mutex>
#include <string>
#include <sys/ioctl.h>
#include <termios.h>
#include <thread>
#include <unistd.h>
#include <vector>
#include "livox_lidar_api.h"
#include "livox_lidar_def.h"
// Global flag for signal handling
volatile std::sig_atomic_t g_running = 1;
void signalHandler(int signum) {
(void)signum;
g_running = 0;
}
// Modbus RTU CRC16 calculation
uint16_t calcCRC16(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (int j = 0; j < 8; ++j) {
if (crc & 0x0001) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
class SerialPort {
private:
int fd_;
std::string port_name_;
public:
SerialPort() : fd_(-1) {}
~SerialPort() { closePort(); }
bool openPort(const std::string &port_name, int baudrate = 115200) {
port_name_ = port_name;
fd_ = open(port_name.c_str(), O_RDWR | O_NOCTTY | O_NDELAY);
if (fd_ < 0) {
std::cerr << "[오류] C++ 포트 열기 실패: " << port_name << std::endl;
return false;
}
fcntl(fd_, F_SETFL, 0);
struct termios options;
tcgetattr(fd_, &options);
speed_t speed = B115200;
switch (baudrate) {
case 9600:
speed = B9600;
break;
case 19200:
speed = B19200;
break;
case 38400:
speed = B38400;
break;
case 57600:
speed = B57600;
break;
default:
speed = B115200;
break;
}
cfsetispeed(&options, speed);
cfsetospeed(&options, speed);
options.c_cflag &= ~PARENB; // No parity
options.c_cflag &= ~CSTOPB; // 1 stop bit
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8; // 8 data bits
options.c_cflag &= ~CRTSCTS; // No hardware flow control
options.c_cflag |= CREAD | CLOCAL;
options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);
options.c_iflag &= ~(IXON | IXOFF | IXANY | IGNBRK | BRKINT | PARMRK |
ISTRIP | INLCR | IGNCR | ICRNL);
options.c_oflag &= ~OPOST;
options.c_cc[VMIN] = 0;
options.c_cc[VTIME] = 1; // 0.1s timeout
tcsetattr(fd_, TCSANOW, &options);
tcflush(fd_, TCIOFLUSH);
return true;
}
void closePort() {
if (fd_ >= 0) {
close(fd_);
fd_ = -1;
}
}
bool writeReg(uint8_t slave, uint16_t reg, uint16_t val) {
uint8_t pkt[8];
pkt[0] = slave;
pkt[1] = 0x06;
pkt[2] = (reg >> 8) & 0xFF;
pkt[3] = reg & 0xFF;
pkt[4] = (val >> 8) & 0xFF;
pkt[5] = val & 0xFF;
uint16_t crc = calcCRC16(pkt, 6);
pkt[6] = crc & 0xFF;
pkt[7] = (crc >> 8) & 0xFF;
tcflush(fd_, TCIOFLUSH);
ssize_t written = write(fd_, pkt, 8);
if (written != 8)
return false;
if (slave == 0)
return true; // Broadcast
uint8_t rx_buf[8];
ssize_t rx_bytes = 0;
auto start = std::chrono::steady_clock::now();
while (rx_bytes < 8) {
ssize_t res = read(fd_, rx_buf + rx_bytes, 8 - rx_bytes);
if (res > 0)
rx_bytes += res;
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(now - start)
.count() > 50)
break;
std::this_thread::sleep_for(std::chrono::microseconds(500));
}
if (rx_bytes != 8)
return false;
// CRC 검증: 손상된 응답을 정상으로 오인하지 않도록 확인
uint16_t resp_crc = calcCRC16(rx_buf, 6);
uint16_t recv_crc = static_cast<uint16_t>(rx_buf[6]) |
(static_cast<uint16_t>(rx_buf[7]) << 8);
return resp_crc == recv_crc && rx_buf[0] == slave;
}
bool writeRegs(uint8_t slave, uint16_t reg,
const std::vector<uint16_t> &vals) {
size_t count = vals.size();
size_t pkt_len = 7 + count * 2 + 2;
std::vector<uint8_t> pkt(pkt_len);
pkt[0] = slave;
pkt[1] = 0x10;
pkt[2] = (reg >> 8) & 0xFF;
pkt[3] = reg & 0xFF;
pkt[4] = (count >> 8) & 0xFF;
pkt[5] = count & 0xFF;
pkt[6] = static_cast<uint8_t>(count * 2);
for (size_t i = 0; i < count; ++i) {
pkt[7 + i * 2] = (vals[i] >> 8) & 0xFF;
pkt[8 + i * 2] = vals[i] & 0xFF;
}
uint16_t crc = calcCRC16(pkt.data(), pkt_len - 2);
pkt[pkt_len - 2] = crc & 0xFF;
pkt[pkt_len - 1] = (crc >> 8) & 0xFF;
tcflush(fd_, TCIOFLUSH);
ssize_t written = write(fd_, pkt.data(), pkt_len);
if (written != static_cast<ssize_t>(pkt_len))
return false;
if (slave == 0)
return true;
uint8_t rx_buf[8];
ssize_t rx_bytes = 0;
auto start = std::chrono::steady_clock::now();
while (rx_bytes < 8) {
ssize_t res = read(fd_, rx_buf + rx_bytes, 8 - rx_bytes);
if (res > 0)
rx_bytes += res;
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(now - start)
.count() > 50)
break;
std::this_thread::sleep_for(std::chrono::microseconds(500));
}
if (rx_bytes != 8)
return false;
uint16_t resp_crc = calcCRC16(rx_buf, 6);
uint16_t recv_crc = static_cast<uint16_t>(rx_buf[6]) |
(static_cast<uint16_t>(rx_buf[7]) << 8);
return resp_crc == recv_crc && rx_buf[0] == slave;
}
bool readRegs(uint8_t slave, uint16_t reg, uint16_t count,
std::vector<uint16_t> &out_vals) {
uint8_t pkt[8];
pkt[0] = slave;
pkt[1] = 0x03;
pkt[2] = (reg >> 8) & 0xFF;
pkt[3] = reg & 0xFF;
pkt[4] = (count >> 8) & 0xFF;
pkt[5] = count & 0xFF;
uint16_t crc = calcCRC16(pkt, 6);
pkt[6] = crc & 0xFF;
pkt[7] = (crc >> 8) & 0xFF;
tcflush(fd_, TCIOFLUSH);
if (write(fd_, pkt, 8) != 8)
return false;
size_t expected_bytes = 5 + count * 2;
std::vector<uint8_t> rx_buf(expected_bytes);
size_t rx_bytes = 0;
auto start = std::chrono::steady_clock::now();
while (rx_bytes < expected_bytes) {
ssize_t res =
read(fd_, rx_buf.data() + rx_bytes, expected_bytes - rx_bytes);
if (res > 0)
rx_bytes += res;
auto now = std::chrono::steady_clock::now();
// 15ms -> 25ms: 100Hz(10ms 주기) 루프 내에서 응답을 안정적으로 받기 위한
// 여유 확보
if (std::chrono::duration_cast<std::chrono::milliseconds>(now - start)
.count() > 25)
break;
std::this_thread::sleep_for(std::chrono::microseconds(100));
}
if (rx_bytes == expected_bytes && rx_buf[0] == slave && rx_buf[1] == 0x03) {
// CRC 검증 추가: 노이즈로 손상된 응답을 정상 데이터로 오인하지 않도록
// 방지
uint16_t resp_crc = calcCRC16(rx_buf.data(), expected_bytes - 2);
uint16_t recv_crc =
static_cast<uint16_t>(rx_buf[expected_bytes - 2]) |
(static_cast<uint16_t>(rx_buf[expected_bytes - 1]) << 8);
if (resp_crc != recv_crc)
return false;
out_vals.resize(count);
for (uint16_t i = 0; i < count; ++i) {
out_vals[i] =
(static_cast<uint16_t>(rx_buf[3 + i * 2]) << 8) | rx_buf[4 + i * 2];
}
return true;
}
return false;
}
};
class MotorDriver {
private:
SerialPort *port_;
uint8_t slave_id_;
public:
MotorDriver(SerialPort *port, uint8_t slave_id)
: port_(port), slave_id_(slave_id) {}
bool initDriver(uint16_t acl_ms = 500, uint16_t dcl_ms = 500) {
port_->writeReg(slave_id_, 0x200E, 0x0006); // Alarm clear
std::this_thread::sleep_for(std::chrono::milliseconds(30));
port_->writeReg(slave_id_, 0x200E, 0x0007); // Disable
std::this_thread::sleep_for(std::chrono::milliseconds(30));
port_->writeReg(slave_id_, 0x200D, 3); // Velocity mode
std::this_thread::sleep_for(std::chrono::milliseconds(30));
port_->writeRegs(slave_id_, 0x2080, {acl_ms, acl_ms}); // Accel
std::this_thread::sleep_for(std::chrono::milliseconds(30));
port_->writeRegs(slave_id_, 0x2082, {dcl_ms, dcl_ms}); // Decel
std::this_thread::sleep_for(std::chrono::milliseconds(30));
port_->writeReg(slave_id_, 0x200E, 0x0008); // Enable
std::this_thread::sleep_for(std::chrono::milliseconds(30));
setBrakes(true);
return true;
}
void setBrakes(bool lock) {
uint16_t val = lock ? 1 : 0;
port_->writeReg(slave_id_, 0x201A, val);
port_->writeReg(slave_id_, 0x201B, val);
}
void setRPMs(float l_rpm, float r_rpm) {
int16_t l_val = static_cast<int16_t>(std::clamp(l_rpm, -3000.0f, 3000.0f));
int16_t r_val = static_cast<int16_t>(std::clamp(r_rpm, -3000.0f, 3000.0f));
port_->writeRegs(
slave_id_, 0x2088,
{static_cast<uint16_t>(l_val), static_cast<uint16_t>(r_val)});
}
bool readFeedback(float &l_fb, float &r_fb, int32_t &l_tick,
int32_t &r_tick, float &l_torque_a, float &r_torque_a,
uint16_t &err_l, uint16_t &err_r, int &l_temp_c,
int &r_temp_c) {
std::vector<uint16_t> regs;
// 0x20A4~0x20AE 11레지스터 연속 읽기: 모터온도(0x20A4)부터 에러코드
// (0x20A5/0x20A6), 포지션 틱, RPM 피드백, 실제 토크(전류)까지 한 번의
// 통신으로 확보. 0x20A4는 기존 읽기 범위 바로 앞이라 추가 통신 없이
// 온도까지 같이 받아온다(과열 사전감지용, doc/02 §읽기전용상태 참고).
// 무부하(공중에 뜬) 바퀴는 전류가 급격히 낮아지므로 슬립 진단에 사용,
// 에러코드는 과전류/과부하 등 드라이버 알람 발생 시 원인 진단에 사용.
if (port_->readRegs(slave_id_, 0x20A4, 11, regs)) {
l_temp_c = static_cast<int8_t>((regs[0] >> 8) & 0xFF);
r_temp_c = static_cast<int8_t>(regs[0] & 0xFF);
err_l = regs[1];
err_r = regs[2];
// 수정: uint32_t -> int32_t 캐스팅은 2의 보수 표현에서 안전하게 부호가
// 재해석됨. 기존의 "val - 0x100000000ULL" 방식은 uint64_t 승격 후
// int32_t로 축소 캐스팅하는 구현정의 동작(현실적으로는 대부분 동작하지만
// 명확성/이식성이 떨어짐)이라 제거함.
uint32_t val_l = (static_cast<uint32_t>(regs[3]) << 16) | regs[4];
l_tick = static_cast<int32_t>(val_l);
uint32_t val_r = (static_cast<uint32_t>(regs[5]) << 16) | regs[6];
r_tick = static_cast<int32_t>(val_r);
int16_t vl = 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
r_fb = static_cast<float>(vr) * 0.1f;
int16_t tl = 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
r_torque_a = static_cast<float>(tr) * 0.1f;
return true;
}
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) 실측 조회. 채널당
// 2레지스터씩 떨어져 있어 L/R 두 번 읽음(설정값이라 시작 시 1회만 조회).
bool readCurrentLimits(uint16_t &rated_l, uint16_t &max_l, uint16_t &rated_r,
uint16_t &max_r) {
std::vector<uint16_t> regs_l, regs_r;
if (!port_->readRegs(slave_id_, 0x2033, 2, regs_l))
return false;
if (!port_->readRegs(slave_id_, 0x2063, 2, regs_r))
return false;
rated_l = regs_l[0];
max_l = regs_l[1];
rated_r = regs_r[0];
max_r = regs_r[1];
return true;
}
// 최대전류(0x2034/0x2064)를 좌/우 동일 값으로 설정. 단위 0.1A.
void setMaxCurrent(uint16_t max_current_01a) {
port_->writeReg(slave_id_, 0x2034, max_current_01a);
port_->writeReg(slave_id_, 0x2064, max_current_01a);
}
};
// 드라이버 에러코드(0x20A5/0x20A6) 비트마스크를 사람이 읽을 수 있는 설명으로 변환
std::string decodeDriverError(uint16_t code) {
if (code == 0)
return "";
std::string s;
auto add = [&](uint16_t bit, const char *name) {
if (code & bit) {
if (!s.empty())
s += "+";
s += name;
}
};
add(0x0001, "과전압");
add(0x0002, "저전압");
add(0x0004, "과전류");
add(0x0008, "과부하");
add(0x0010, "전류이상(예약)");
add(0x0020, "엔코더오차");
add(0x0040, "속도이상(예약)");
add(0x0080, "기준전압오류");
add(0x0100, "EEPROM오류");
add(0x0200, "홀센서오류");
add(0x0400, "모터과열");
add(0x0800, "엔코더오류");
add(0x2000, "속도설정오류");
if (s.empty()) {
char buf[32];
snprintf(buf, sizeof(buf), "알수없음(0x%04X)", code);
s = buf;
}
return s;
}
// -----------------------------------------------------------------------------
// Livox Mid-360S Single LiDAR Obstacle Detection & Avoidance Class
// -----------------------------------------------------------------------------
struct ObstacleStatus {
float min_dist_front = 999.0f; // m
float min_dist_rear = 999.0f; // m
float min_dist_left = 999.0f; // m
float min_dist_right = 999.0f; // m
bool connected = false;
std::chrono::steady_clock::time_point last_update;
};
class LidarObstacleDetector {
private:
std::string config_path_;
std::atomic<bool> initialized_{false};
std::mutex status_mutex_;
ObstacleStatus status_;
// Filtering & ROI parameters
float min_z_ = -0.40f; // m (Z axis relative to LiDAR after tilt rotation)
float max_z_ = 0.80f; // m
float min_r_ = 0.15f; // m (blind spot / chassis)
float max_r_ = 3.50f; // m (max range)
float robot_half_width_ =
0.255f; // m (robot width 0.410m / 2 + 0.05m margin = 0.255m)
float pitch_deg_ = 17.0f; // deg (LiDAR downward tilt angle)
float lidar_height_ = 0.460f; // m (LiDAR height above ground)
float lidar_x_offset_ =
0.250f; // m (LiDAR forward offset 250mm from robot center)
float cos_pitch_ = std::cos(17.0f * 3.14159265f / 180.0f);
float sin_pitch_ = std::sin(17.0f * 3.14159265f / 180.0f);
public:
static LidarObstacleDetector *instance_;
LidarObstacleDetector() { instance_ = this; }
~LidarObstacleDetector() { stop(); }
void setParams(float min_z, float max_z, float robot_half_width,
float pitch_deg = 17.0f, float lidar_height = 0.460f,
float lidar_x_offset = 0.250f) {
min_z_ = min_z;
max_z_ = max_z;
robot_half_width_ = robot_half_width;
pitch_deg_ = pitch_deg;
lidar_height_ = lidar_height;
lidar_x_offset_ = lidar_x_offset;
float pitch_rad = pitch_deg_ * 3.14159265358979323846f / 180.0f;
cos_pitch_ = std::cos(pitch_rad);
sin_pitch_ = std::sin(pitch_rad);
}
bool init(const std::string &config_path) {
config_path_ = config_path;
if (!LivoxLidarSdkInit(config_path_.c_str())) {
std::cerr << "[경고] Livox Mid-360S SDK2 초기화 실패 (" << config_path_
<< ")\n";
return false;
}
SetLivoxLidarPointCloudCallBack(PointCloudCallbackStatic, nullptr);
SetLivoxLidarInfoChangeCallback(InfoChangeCallbackStatic, nullptr);
initialized_ = true;
std::cout << "[정보] Livox Mid-360S 라이다 수신 시작 (설정: "
<< config_path_ << ", 피치 기울임: " << pitch_deg_
<< "도, 설치높이: " << lidar_height_ * 1000.0f
<< "mm, 전방 오프셋: " << lidar_x_offset_ * 1000.0f << "mm)\n";
return true;
}
void stop() {
if (initialized_) {
LivoxLidarSdkUninit();
initialized_ = false;
std::cout << "[정보] Livox Mid-360S 라이다 수신기 종료.\n";
}
}
static void PointCloudCallbackStatic(const uint32_t handle,
const uint8_t dev_type,
LivoxLidarEthernetPacket *data,
void *client_data) {
(void)dev_type;
(void)client_data;
if (instance_)
instance_->handlePointCloud(handle, data);
}
static void InfoChangeCallbackStatic(const uint32_t handle,
const LivoxLidarInfo *info,
void *client_data) {
(void)client_data;
if (!info)
return;
std::cout << "\n[라이다 연결 완료] Handle: " << handle
<< ", SN: " << info->sn << ", IP: " << info->lidar_ip
<< std::endl;
SetLivoxLidarWorkMode(handle, kLivoxLidarNormal, nullptr, nullptr);
}
void handlePointCloud(uint32_t handle, LivoxLidarEthernetPacket *packet) {
(void)handle;
if (!packet)
return;
float curr_front = 999.0f;
float curr_rear = 999.0f;
float curr_left = 999.0f;
float curr_right = 999.0f;
auto parsePoint = [&](float raw_x, float raw_y, float raw_z) {
// Apply 17 degree pitch rotation (LiDAR tilted downward towards ground)
float x = raw_x * cos_pitch_ + raw_z * sin_pitch_;
float y = raw_y;
float z = -raw_x * sin_pitch_ + raw_z * cos_pitch_;
if (z < min_z_ || z > max_z_)
return; // Filter ground floor and ceiling
float r = std::sqrt(x * x + y * y);
if (r < min_r_ || r > max_r_)
return;
// Coordinate Frame: +X Forward, +Y Left, +Z Up
// 1. Front Obstacle Zone
if (x > 0.05f && std::abs(y) <= robot_half_width_) {
if (x < curr_front)
curr_front = x;
}
// 2. Rear Obstacle Zone
else if (x < -0.05f && std::abs(y) <= robot_half_width_) {
if (-x < curr_rear)
curr_rear = -x;
}
// 3. Side Obstacle Zones (Front Lookahead window: X in [0.05m, 1.8m])
if (x > 0.05f && x < 1.8f) {
if (y > 0.10f && y < 1.2f) { // Left Side
float dist = std::sqrt(x * x + y * y);
if (dist < curr_left)
curr_left = dist;
} else if (y < -0.10f && y > -1.2f) { // Right Side
float dist = std::sqrt(x * x + y * y);
if (dist < curr_right)
curr_right = dist;
}
}
};
if (packet->data_type == kLivoxLidarCartesianCoordinateHighData) {
LivoxLidarCartesianHighRawPoint *pts =
(LivoxLidarCartesianHighRawPoint *)packet->data;
for (uint32_t i = 0; i < packet->dot_num; ++i) {
parsePoint(pts[i].x / 1000.0f, pts[i].y / 1000.0f, pts[i].z / 1000.0f);
}
} else if (packet->data_type == kLivoxLidarCartesianCoordinateLowData) {
LivoxLidarCartesianLowRawPoint *pts =
(LivoxLidarCartesianLowRawPoint *)packet->data;
for (uint32_t i = 0; i < packet->dot_num; ++i) {
parsePoint(pts[i].x / 100.0f, pts[i].y / 100.0f, pts[i].z / 100.0f);
}
}
std::lock_guard<std::mutex> lock(status_mutex_);
auto updateEMA = [](float &old_val, float new_val) {
if (new_val < old_val) {
old_val = new_val; // Fast response to approaching obstacles
} else {
old_val = old_val * 0.70f + new_val * 0.30f; // Smooth recovery
}
};
updateEMA(status_.min_dist_front, curr_front);
updateEMA(status_.min_dist_rear, curr_rear);
updateEMA(status_.min_dist_left, curr_left);
updateEMA(status_.min_dist_right, curr_right);
status_.connected = true;
status_.last_update = std::chrono::steady_clock::now();
}
ObstacleStatus getStatus() {
std::lock_guard<std::mutex> lock(status_mutex_);
auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(
now - status_.last_update)
.count() > 800) {
status_.connected = false;
}
return status_;
}
};
LidarObstacleDetector *LidarObstacleDetector::instance_ = nullptr;
// -----------------------------------------------------------------------------
// RadioMaster Pocket + XR1 V1.0 RC 수신기 (USB-to-TTL 시리얼) 입력 모듈
// 실기 캡처로 확인한 실제 프로토콜: CRSF(Crossfire), 420000bps, 8N1.
// 프레임: [0]=Sync(0xC8) [1]=Length [2]=FrameType [3..]=Payload [-1]=CRC8
// FrameType 0x16(RC_CHANNELS_PACKED)의 22바이트 payload에 16채널이 11비트씩
// 리틀엔디안으로 패킹되어 있다 (raw 172~1811 -> 1000~2000us로 선형 변환).
// 참고: mac/mac_code (맥에서 정상 동작 확인된 레퍼런스 파이썬 스크립트)와
// 동일한 파싱 방식을 따른다 — 실측 결과 CRC8이 표준 DVB-S2(poly 0xD5) 값과
// 맞지 않아(송신기별 구현 차이로 추정) 레퍼런스와 동일하게 CRC는 검증하지
// 않고 Sync+Length 기반 프레이밍만 신뢰한다.
// -----------------------------------------------------------------------------
#ifndef BOTHER
#define BOTHER 0010000 // 커널 termbits.h: CBAUDEX와 동일값, 임의 보드레이트 지정용
#endif
struct termios2 { // <asm/termbits.h>의 커널 ABI와 동일 레이아웃 (glibc의
// struct termios와 이름 충돌을 피하기 위해 직접 선언).
// 420000bps처럼 표준 B-상수가 없는 보드레이트를
// TCSETS2/BOTHER ioctl로 설정하기 위해 필요하다.
tcflag_t c_iflag, c_oflag, c_cflag, c_lflag;
cc_t c_line;
cc_t c_cc[19];
speed_t c_ispeed, c_ospeed;
};
struct RcConfig {
// udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된
// 심볼릭 링크. ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름은 고정.
std::string port = "/dev/ttyRC";
int baud = 420000; // CRSF 표준 보드레이트
int ch_steer = 1; // CH1: 좌우 조향
int ch_throttle = 2; // CH2: 전후진
int ch_brake = 5; // CH5: 비상 브레이크
int ch_speed = 6; // CH6: 속도 모드
// 실기 캡처로 확인한 실측값 (좌 1005 ~ 중 1481~1503 ~ 우 2000)
int steer_min = 1005, steer_center_lo = 1481, steer_center_hi = 1503,
steer_max = 2000;
// 실측값: 후진끝 1005 ~ 중 1500 ~ 전진끝 2000 (CH2는 값이 높을수록 전진 —
// CH1과 반대 방향이라 아래 3.3 매핑 로직도 그에 맞춰 부호가 반대다)
int throttle_min = 1005, throttle_center_lo = 1480, throttle_center_hi = 1520,
throttle_max = 2000;
int brake_threshold = 1500; // 이상이면 브레이크 ON
int speed_threshold = 1500; // 이상이면 저속 모드
// CH1/CH2(연속 아날로그 스틱) 슬루레이트 한계: 메인 루프 한 틱(~10ms)
// 사이에 raw 값이 이 이상 튀면 사람이 스틱으로 낼 수 없는 변화로 보고
// 손상된 프레임으로 간주해 버린다. CH5/CH6(스위치)는 원래 한 번에 크게
// 뛰는 게 정상이라 이 제한을 적용하지 않는다.
int max_slew_per_tick = 200;
// 슬루레이트 거부가 연속으로 이 틱 수 이상 이어지면(=한 번의 손상
// 프레임이 아니라 실제로 스틱이 그만큼 빠르게/멀리 움직인 상황으로
// 판단) 그냥 최신값을 그대로 받아들여 재동기화한다. 이 escape hatch가
// 없으면 range는 정상인데 slew만 거부된 값 하나가 last_raw_*를 그
// 자리에 영원히 고정시켜버려서, 이후 들어오는 진짜 값들도 그 고정된
// 기준과 계속 200 이상 차이나 버려 채널이 그 값에 영구히 멈춰버린다.
int max_reject_streak = 3;
float v_max_low = 0.3f; // m/s (CH6 저속 모드)
float v_max_high = 1.5f; // m/s (CH6 고속 모드)
int failsafe_timeout_ms = 200; // 이 시간 이상 신호 미수신 시 비상정지
};
class RcReceiver {
public:
explicit RcReceiver(const RcConfig &cfg) : cfg_(cfg) {}
~RcReceiver() { stop(); }
void start() {
running_ = true;
thread_ = std::thread(&RcReceiver::run, this);
}
void stop() {
running_ = false;
if (thread_.joinable())
thread_.join();
closePort();
}
// 채널 raw 값 조회. 아직 한 번도 수신하지 못했으면 false.
bool getChannel(int ch, int &out_val) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = channels_.find(ch);
if (it == channels_.end())
return false;
out_val = it->second;
return true;
}
// 포트가 열려 있고, failsafe_timeout_ms 이내에 유효한 패킷을 수신했는가.
bool isConnected() {
std::lock_guard<std::mutex> lock(mutex_);
if (!port_open_ || channels_.empty())
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;
}
private:
RcConfig cfg_;
std::atomic<bool> running_{false};
std::thread thread_;
int fd_ = -1;
std::mutex mutex_;
std::map<int, int> channels_;
std::chrono::steady_clock::time_point last_rx_time_{};
bool port_open_ = false;
std::vector<uint8_t> frame_buf_;
// 채널별 미디언(median-of-3) 필터 히스토리. 이 송신기는 CRC/체크섬이
// 표준 방식으로 검증되지 않아(전수조사로 확인) 단발성 손상 프레임이
// 섞여 들어올 수 있는데, 최근 3개 raw 값 중 가운데 값을 채택하면 3개
// 중 하나가 손상돼도 자동으로 걸러진다. 스위치 채널도 전환 시 최대
// 1프레임(~4ms)만 지연될 뿐이라 안전하게 전 채널에 적용 가능하다.
std::map<int, std::array<int, 2>> raw_history_;
std::map<int, int> history_count_;
static int median3(int a, int b, int c) {
if ((a <= b && b <= c) || (c <= b && b <= a))
return b;
if ((b <= a && a <= c) || (c <= a && a <= b))
return a;
return c;
}
static constexpr uint8_t CRSF_SYNC = 0xC8;
static constexpr uint8_t CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16;
static constexpr size_t CRSF_MAX_FRAME_LEN = 64; // 스펙상 프레임은 훨씬 작음
bool openPort() {
fd_ = open(cfg_.port.c_str(), O_RDWR | O_NOCTTY | O_NDELAY);
if (fd_ < 0)
return false;
fcntl(fd_, F_SETFL, O_NONBLOCK);
// CRSF 표준 420000bps는 termios의 표준 B-상수 목록에 없어 TCSETS2 +
// BOTHER + c_ispeed/c_ospeed(임의 정수 보드레이트) ioctl로 설정한다.
struct termios2 tio;
if (ioctl(fd_, TCGETS2, &tio) < 0) {
close(fd_);
fd_ = -1;
return false;
}
tio.c_cflag &= ~CBAUD;
tio.c_cflag |= BOTHER;
tio.c_ispeed = static_cast<speed_t>(cfg_.baud);
tio.c_ospeed = static_cast<speed_t>(cfg_.baud);
tio.c_cflag &= ~PARENB;
tio.c_cflag &= ~CSTOPB;
tio.c_cflag &= ~CSIZE;
tio.c_cflag |= CS8;
tio.c_cflag &= ~CRTSCTS;
tio.c_cflag |= CREAD | CLOCAL;
// 바이너리 프로토콜이므로 raw 모드(비정규/논캐노니컬)로 읽는다.
tio.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG | IEXTEN);
tio.c_iflag &= ~(IXON | IXOFF | IXANY | IGNBRK | BRKINT | PARMRK |
ISTRIP | INLCR | IGNCR | ICRNL);
tio.c_oflag &= ~OPOST;
tio.c_cc[VMIN] = 0;
tio.c_cc[VTIME] = 0;
if (ioctl(fd_, TCSETS2, &tio) < 0) {
close(fd_);
fd_ = -1;
return false;
}
tcflush(fd_, TCIOFLUSH);
return true;
}
void closePort() {
if (fd_ >= 0) {
close(fd_);
fd_ = -1;
}
std::lock_guard<std::mutex> lock(mutex_);
port_open_ = false;
}
// 축적된 바이트 버퍼에서 CRSF 프레임을 최대한 뽑아낸다. Sync 바이트를
// 찾고, 선언된 Length만큼 버퍼에 데이터가 쌓일 때까지 기다린 뒤
// RC_CHANNELS_PACKED(0x16) 프레임이면 16채널(11비트씩)을 언패킹한다.
// (CRC8은 이 송신기에서 표준 DVB-S2 값과 맞지 않아 검증하지 않음 — 실기
// 캡처로 확인된 mac/mac_code 레퍼런스와 동일한 방식.)
void parseCrsfBuffer() {
std::map<int, int> parsed;
size_t i = 0;
while (i < frame_buf_.size()) {
if (frame_buf_[i] != CRSF_SYNC) {
++i;
continue;
}
if (i + 1 >= frame_buf_.size())
break; // length 바이트 대기
uint8_t length = frame_buf_[i + 1];
size_t total_len = static_cast<size_t>(length) + 2;
if (length < 2 || total_len > CRSF_MAX_FRAME_LEN) {
// Sync처럼 보이는 노이즈 바이트: 1바이트만 버리고 재동기화
++i;
continue;
}
if (i + total_len > frame_buf_.size())
break; // 프레임 전체 수신 대기
uint8_t frame_type = frame_buf_[i + 2];
if (frame_type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED && length >= 23) {
// payload = frame_buf_[i+3 .. i+total_len-2] (CRC 바이트 제외 22바이트
// = 176비트 = 16채널×11비트). uint64_t 하나에 통째로 담으면 64비트를
// 넘는 시프트가 발생해 정의되지 않은 동작(UB)이 되므로, 32비트 롤링
// 비트 누산기로 바이트를 하나씩 채워가며 11비트씩 뽑아낸다.
uint32_t bit_accum = 0;
int bit_count = 0;
int byte_idx = 0;
for (int ch = 0; ch < 16; ++ch) {
while (bit_count < 11) {
bit_accum |= static_cast<uint32_t>(frame_buf_[i + 3 + byte_idx])
<< bit_count;
bit_count += 8;
++byte_idx;
}
int raw_val = static_cast<int>(bit_accum & 0x7FFU);
bit_accum >>= 11;
bit_count -= 11;
int us_val = static_cast<int>(
std::lround((raw_val - 172) * 1000.0 / 1639.0 + 1000.0));
// 미디언-3 필터: 최근 2개 raw 값 + 이번 값 중 가운데 값을 채택.
// 히스토리는 항상 raw 값으로 갱신한다(필터링된 결과가 아니라).
int ch_num = ch + 1;
std::array<int, 2> &hist = raw_history_[ch_num];
int &count = history_count_[ch_num];
int filtered = us_val;
if (count >= 2) {
filtered = median3(hist[0], hist[1], us_val);
} else {
++count; // 워밍업 구간(처음 2개)은 그대로 통과
}
hist[0] = hist[1];
hist[1] = us_val;
parsed[ch_num] = filtered; // CH1..CH16 (1-based)
}
}
i += total_len;
}
// 처리하지 못한 잔여 바이트(다음 프레임 일부)만 남기고 버퍼 정리
frame_buf_.erase(frame_buf_.begin(), frame_buf_.begin() + i);
if (frame_buf_.size() > CRSF_MAX_FRAME_LEN * 4) // 비정상 누적 방지
frame_buf_.clear();
if (!parsed.empty()) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto &kv : parsed)
channels_[kv.first] = kv.second;
last_rx_time_ = std::chrono::steady_clock::now();
}
}
void run() {
// now() - 1001ms로 초기화: time_point::min()을 쓰면 첫 비교에서
// "now - sentinel"이 부호있는 정수 오버플로우(UB)를 일으켜 -O3에서
// 재연결 시도 자체가 영구히 스킵될 수 있다.
auto last_open_attempt =
std::chrono::steady_clock::now() - std::chrono::milliseconds(1001);
char buf[256];
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[정보] RC 수신기 포트(" << cfg_.port
<< ") 연결 성공.\n";
} else {
std::cerr << "\n[경고] RC 수신기 포트(" << cfg_.port
<< ") 열기 실패. 1초 후 재시도.\n";
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
ssize_t n = read(fd_, buf, sizeof(buf));
if (n > 0) {
frame_buf_.insert(frame_buf_.end(), buf, buf + n);
parseCrsfBuffer();
} else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
std::cerr << "\n[경고] RC 수신기 포트 통신 오류. 재연결 시도...\n";
closePort();
} else {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
}
}
};
// -----------------------------------------------------------------------------
// 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) {
std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, signalHandler);
// udev 규칙(/etc/udev/rules.d/99-fori-robot-serial.rules)으로 고정된
// 심볼릭 링크. ttyUSB 번호는 꽂는 순서에 따라 바뀌지만 이 이름은 고정.
std::string port1 = "/dev/ttyMOTOR";
std::string port2 = "";
uint8_t id1 = 1;
uint8_t id2 = 2;
bool bcast_mode = false;
RcConfig rc_cfg;
float max_spin_v = 0.30f; // m/s
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로 즉시
// 추종하므로 별도의 가속 한계값이 없다. decel_rate/jerk_rate(및
// spin_jerk_rate)는 감속에만 적용된다.
float decel_rate = 180.0f; // RPM/s (최대 감속도 한계)
float jerk_rate = 600.0f; // RPM/s^2 (직진/커브 선회 감속 저크 한계)
float spin_jerk_rate = 220.0f; // RPM/s^2 (제자리 회전 감속 저크 한계)
float spin_arc_bias = 0.12f; // 0~1 (제자리 회전 시 섞을 최소 회전반경용
// 미세 전진 성분 비율. ICR을 로봇 중심에서
// 살짝 벗어나게 해 스크럽 마찰 저항을 줄임)
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에 곱하는 배율
// 바퀴별 이상(들뜸/걸림) 판정 파라미터: 속도 폐루프 특성상 "지령 대비 실제
// 속도" 하나만으로는 무부하(들뜸) 판정이 안 되므로, 동료 바퀴 대비 전류
// 편차와 함께 두 축으로 판정한다.
float min_active_rpm = 3.0f; // RPM (이 미만이면 판정 보류: 정지 취급)
float airborne_current_ratio = 0.35f; // 동료 바퀴 중앙값 전류 대비 이 비율
// 미만이면 무부하(들뜸)로 판정
float stall_vel_ratio = 0.5f; // 실제속도/지령속도가 이 비율 미만이면
// 속도 지연으로 판단(걸림 후보)
float stall_current_a = 12.0f; // A (정격 15A 근접) 이상이면서 속도
// 지연이 동반되면 걸림/과부하로 판정
int airborne_debounce_ticks = 5; // 틱 (100Hz 기준 50ms) 연속 들뜸 판정
// 시에만 실제로 목표속도를 낮춤
// 비어있으면 프로그램 시작 시 logs/drive_YYYYmmdd_HHMMSS.csv로 자동 생성된다
// (매 주행마다 RC/IMU/모터 로깅을 자동으로 별도 파일에 남기기 위함).
// --log <파일경로>로 직접 지정하거나 --no_log로 완전히 끌 수 있다.
std::string log_path = "";
bool no_log = false;
// 실측 결과 정격15A/최대30A(공장 기본값) 그대로였음. 정격 위로 5A 여유는
// 남기되, 걸림/과부하 상황에서 30A까지 밀어붙이며 3초씩 버티다 과부하
// 알람이 터지는 걸 막기 위해 기본값을 20A로 낮춤. --max_current_a로 조정 가능.
// 음수를 주면 미변경(공장/기존 설정 유지).
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)
bool use_lidar = true;
std::string lidar_config = "mid360s_config.json";
float front_stop_dist = 0.35f; // m (전방 완전 정지 거리 0.35m)
float front_warn_dist = 0.50f; // m (전방 감속/경고 시작 거리 0.50m = 50cm)
float side_dodge_dist = 0.50f; // m (측면 장애물 우회 감지 거리 0.50m = 50cm)
float side_stop_dist = 0.20f; // m (측면 한계 접근 거리 0.20m)
float max_dodge_omega = 0.35f; // rad/s (자동 회피 최대 조향 각속도)
float robot_width = 0.410f; // m (로봇 가로/좌우 실측 전폭 410mm)
float robot_length = 0.631f; // m (로봇 세로/전후 실측 전장 631mm)
float robot_height = 0.286f; // m (로봇 전고 286mm)
float lidar_height = 0.460f; // m (지면 기준 라이다 높이 460mm)
float lidar_pitch = 17.0f; // deg (라이다 하향 기울임 17도)
float lidar_x_offset = 0.250f; // m (로봇 중심 기준 라이다 전방 오프셋 250mm)
float min_z = -lidar_height + 0.06f; // m (-0.40m: 지면 감지 방지 안전 오프셋)
float max_z = 0.80f; // m (라이다 기준 상단 높이)
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--port" && i + 1 < argc)
port1 = argv[++i];
else if (arg == "--port1" && i + 1 < argc)
port1 = argv[++i];
else if (arg == "--port2" && i + 1 < argc)
port2 = argv[++i];
else if (arg == "--id1" && i + 1 < argc)
id1 = static_cast<uint8_t>(std::stoi(argv[++i]));
else if (arg == "--id2" && i + 1 < argc)
id2 = static_cast<uint8_t>(std::stoi(argv[++i]));
else if (arg == "--k_skid" && i + 1 < argc)
k_skid_override = std::stof(argv[++i]);
else if (arg == "--effective_w" && i + 1 < argc)
effective_w_override = std::stof(argv[++i]);
else if (arg == "--radius" && i + 1 < argc)
wheel_radius = std::stof(argv[++i]);
else if (arg == "--bumper" && i + 1 < argc)
bumper_offset = std::stof(argv[++i]);
else if (arg == "--decel" && i + 1 < argc)
decel_rate = std::stof(argv[++i]);
else if (arg == "--jerk" && i + 1 < argc)
jerk_rate = std::stof(argv[++i]);
else if (arg == "--spin_jerk" && i + 1 < argc)
spin_jerk_rate = std::stof(argv[++i]);
else if (arg == "--spin_arc_bias" && i + 1 < argc)
spin_arc_bias = std::stof(argv[++i]);
else if (arg == "--min_active_rpm" && i + 1 < argc)
min_active_rpm = std::stof(argv[++i]);
else if (arg == "--airborne_current_ratio" && i + 1 < argc)
airborne_current_ratio = std::stof(argv[++i]);
else if (arg == "--stall_vel_ratio" && i + 1 < argc)
stall_vel_ratio = std::stof(argv[++i]);
else if (arg == "--stall_current_a" && i + 1 < argc)
stall_current_a = std::stof(argv[++i]);
else if (arg == "--airborne_debounce_ticks" && i + 1 < argc)
airborne_debounce_ticks = std::stoi(argv[++i]);
else if (arg == "--log" && i + 1 < argc)
log_path = argv[++i];
else if (arg == "--no_log")
no_log = true;
else if (arg == "--max_current_a" && i + 1 < argc)
max_current_a = std::stof(argv[++i]);
else if (arg == "--bcast" || arg == "--broadcast")
bcast_mode = true;
else if (arg == "--lidar_config" && i + 1 < argc)
lidar_config = argv[++i];
else if (arg == "--front_stop" && i + 1 < argc)
front_stop_dist = std::stof(argv[++i]);
else if (arg == "--front_warn" && i + 1 < argc)
front_warn_dist = std::stof(argv[++i]);
else if (arg == "--side_dodge" && i + 1 < argc)
side_dodge_dist = std::stof(argv[++i]);
else if (arg == "--robot_width" && i + 1 < argc)
robot_width = std::stof(argv[++i]);
else if (arg == "--lidar_height" && i + 1 < argc)
lidar_height = std::stof(argv[++i]);
else if (arg == "--lidar_pitch" && i + 1 < argc)
lidar_pitch = std::stof(argv[++i]);
else if (arg == "--lidar_x_offset" && i + 1 < argc)
lidar_x_offset = std::stof(argv[++i]);
else if (arg == "--min_z" && i + 1 < argc)
min_z = std::stof(argv[++i]);
else if (arg == "--max_z" && i + 1 < argc)
max_z = std::stof(argv[++i]);
else if (arg == "--no_lidar")
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)
rc_cfg.port = argv[++i];
else if (arg == "--rc_baud" && i + 1 < argc)
rc_cfg.baud = std::stoi(argv[++i]);
else if (arg == "--rc_ch_steer" && i + 1 < argc)
rc_cfg.ch_steer = std::stoi(argv[++i]);
else if (arg == "--rc_ch_throttle" && i + 1 < argc)
rc_cfg.ch_throttle = std::stoi(argv[++i]);
else if (arg == "--rc_ch_brake" && i + 1 < argc)
rc_cfg.ch_brake = std::stoi(argv[++i]);
else if (arg == "--rc_ch_speed" && i + 1 < argc)
rc_cfg.ch_speed = std::stoi(argv[++i]);
else if (arg == "--rc_steer_min" && i + 1 < argc)
rc_cfg.steer_min = std::stoi(argv[++i]);
else if (arg == "--rc_steer_center_lo" && i + 1 < argc)
rc_cfg.steer_center_lo = std::stoi(argv[++i]);
else if (arg == "--rc_steer_center_hi" && i + 1 < argc)
rc_cfg.steer_center_hi = std::stoi(argv[++i]);
else if (arg == "--rc_steer_max" && i + 1 < argc)
rc_cfg.steer_max = std::stoi(argv[++i]);
else if (arg == "--rc_throttle_min" && i + 1 < argc)
rc_cfg.throttle_min = std::stoi(argv[++i]);
else if (arg == "--rc_throttle_center_lo" && i + 1 < argc)
rc_cfg.throttle_center_lo = std::stoi(argv[++i]);
else if (arg == "--rc_throttle_center_hi" && i + 1 < argc)
rc_cfg.throttle_center_hi = std::stoi(argv[++i]);
else if (arg == "--rc_throttle_max" && i + 1 < argc)
rc_cfg.throttle_max = std::stoi(argv[++i]);
else if (arg == "--rc_brake_threshold" && i + 1 < argc)
rc_cfg.brake_threshold = std::stoi(argv[++i]);
else if (arg == "--rc_speed_threshold" && i + 1 < argc)
rc_cfg.speed_threshold = std::stoi(argv[++i]);
else if (arg == "--rc_max_slew" && i + 1 < argc)
rc_cfg.max_slew_per_tick = std::stoi(argv[++i]);
else if (arg == "--rc_max_reject_streak" && i + 1 < argc)
rc_cfg.max_reject_streak = std::stoi(argv[++i]);
else if (arg == "--rc_vmax_low" && i + 1 < argc)
rc_cfg.v_max_low = std::stof(argv[++i]);
else if (arg == "--rc_vmax_high" && i + 1 < argc)
rc_cfg.v_max_high = std::stof(argv[++i]);
else if (arg == "--rc_failsafe_ms" && i + 1 < argc)
rc_cfg.failsafe_timeout_ms = std::stoi(argv[++i]);
else if (arg == "--max_spin_v" && i + 1 < argc)
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;
float rpm_per_ms = 60.0f / (2.0f * PI_VAL * wheel_radius);
// effective_w — Phase 5a 정적 캘리브레이션. 기하학적 공식값(대각선 길이,
// 0.684)은 제자리 회전 실측 대비 작았다. 전용 회전 로그 2개(무결점
// 405틱, 두 로그 개별 중앙값 0.855/0.904로 서로 일치)에서 역산한
// 중앙값 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============================================================="
"=====\n";
std::cout
<< " [C++17 4WD ZLAC8015D + Mid-360S 라이다 정밀 장애물 회피 시스템]\n";
std::cout << " - 로봇 스펙: 가로(전폭) " << robot_width * 1000.0f
<< "mm | 세로(전장) " << robot_length * 1000.0f << "mm | 높이 "
<< robot_height * 1000.0f << "mm\n";
std::cout << " - 라이다 설치: 지면 높이 " << lidar_height * 1000.0f
<< "mm | 피치 경사각 " << lidar_pitch << "도 (하향) | 전방 오프셋 "
<< lidar_x_offset * 1000.0f << "mm\n";
std::cout << " - 필터링 범위: 높이 Z(" << min_z << "m ~ " << max_z
<< "m) | 회피 정밀 전폭 " << (robot_width + 0.10f) * 1000.0f
<< "mm\n";
std::cout << " - 최고 속도: 저속 " << rc_cfg.v_max_low << "m/s / 고속 "
<< rc_cfg.v_max_high << "m/s (CH" << rc_cfg.ch_speed
<< " 전환) | 가속: 즉시 추종(1:1) | 감속 Jerk: " << jerk_rate
<< "RPM/s² (제자리회전: " << spin_jerk_rate
<< "RPM/s²) | 회전 곡선혼합: " << spin_arc_bias * 100.0f << "%\n";
std::cout << " - RC 수신기: " << rc_cfg.port << " @" << rc_cfg.baud
<< "bps | 조향 CH" << rc_cfg.ch_steer << " | 스로틀 CH"
<< rc_cfg.ch_throttle << " | 브레이크 CH" << rc_cfg.ch_brake
<< " | 속도모드 CH" << rc_cfg.ch_speed
<< " | Fail-safe: " << rc_cfg.failsafe_timeout_ms << "ms\n";
std::cout << " - 휠 이상판정: 무부하(들뜸) 전류비 " << airborne_current_ratio
<< " | 걸림 속도비 " << stall_vel_ratio << " / 전류 "
<< stall_current_a << "A 이상\n";
std::cout << " - 라이다 장애물 회피 (LiDAR Dodge): "
<< (use_lidar ? "활성화 (Mid-360S)" : "비활성화") << "\n";
std::cout << " - 전방 정지 거리: " << front_stop_dist
<< "m | 전방 경고: " << front_warn_dist
<< "m | 측면 회피: " << side_dodge_dist << "m\n";
std::cout
<< "==================================================================\n";
// CSV 로깅: 기본적으로 매 주행마다 logs/에 자동 저장된다(위 auto-path 로직).
// RC 채널, IMU(자이로/각도), 모터 지령/피드백/전류/휠상태/엔코더 raw tick까지
// 매 틱 전부 기록한다. 실외에서 로봇을 조종하며 화면을 동시에 읽기 어려우므로,
// 문제가 된 구간(턱 넘는 지점 등)이나 IMU-엔코더 융합용 데이터를 나중에 잘라서
// 분석하기 위한 용도.
std::ofstream log_stream;
if (!log_path.empty()) {
log_stream.open(log_path);
if (log_stream.is_open()) {
log_stream << "t_ms,state,rc_steer,rc_throttle,rc_ok,brake,vmax,vx,omega,"
"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,"
"fb_fl,fb_fr,fb_rl,fb_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,"
"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";
std::cout << " - CSV 로그 기록: " << log_path << "\n";
} else {
std::cerr << "[경고] 로그 파일 열기 실패: " << log_path << "\n";
}
}
auto log_start_time = std::chrono::steady_clock::now();
// Initialize LiDAR Detector
LidarObstacleDetector lidar_detector;
if (use_lidar) {
// Robot half width tolerance = robot_width / 2 + 5cm margin
lidar_detector.setParams(min_z, max_z, robot_width / 2.0f + 0.05f,
lidar_pitch, lidar_height, lidar_x_offset);
if (!lidar_detector.init(lidar_config)) {
std::cout << "[경고] 라이다 연결 실패. 장애물 감지 없이 조이스틱 모드로 "
"실행합니다.\n";
}
}
// 모터(--port)와 RC 수신기(--rc_port)가 같은 물리 포트를 가리키면 두
// 스레드가 같은 장치의 read()를 두고 경쟁해 RC 라인 파싱이 깨진다.
// 흔한 실수(예: 모터 미연결 상태에서 RC 수신기가 ttyUSB0에 꽂혀있는데
// --rc_port를 기본값 ttyUSB1로 둔 채 실행)를 바로 알아차리도록 경고한다.
if (port1 == rc_cfg.port) {
std::cout << "\n[경고] --port(모터, " << port1 << ")와 --rc_port(RC 수신기, "
<< rc_cfg.port
<< ")가 동일한 포트로 설정되어 있습니다. 두 기능이 같은 장치를 "
"동시에 읽으면 RC 데이터 파싱이 깨질 수 있으니 서로 다른 "
"포트로 지정하세요.\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);
rc.start();
std::cout << "[정보] RadioMaster Pocket / XR1 V1.0 RC 수신기 리더 시작 ("
<< rc_cfg.port << "). 신호 수신 전까지는 안전을 위해 브레이크가 "
"걸린 상태로 대기합니다.\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;
if (!sp1.openPort(port1))
return 1;
std::cout << "[정보] RS485 포트1 (" << port1 << ") 오픈 성공.\n";
SerialPort *sp2_ptr = &sp1;
if (!port2.empty()) {
if (sp2.openPort(port2)) {
sp2_ptr = &sp2;
std::cout << "[정보] RS485 포트2 (" << port2 << ") 오픈 성공.\n";
}
}
MotorDriver driver_front(&sp1, bcast_mode ? 0 : id1);
driver_front.initDriver(150, 150);
MotorDriver *driver_rear_ptr = nullptr;
MotorDriver driver_rear(sp2_ptr, bcast_mode ? 0 : id2);
if (!bcast_mode) {
driver_rear.initDriver(150, 150);
driver_rear_ptr = &driver_rear;
}
// 현재 드라이버에 실제로 설정된 정격/최대전류를 읽어와서 보여준다
// (문서 기본값이 아니라 실제 하드웨어에 저장된 값을 확인하기 위함).
// --max_current_a가 지정되면 4바퀴 최대전류를 그 값으로 낮춘다.
if (!bcast_mode) {
uint16_t f_rated_l, f_max_l, f_rated_r, f_max_r;
if (driver_front.readCurrentLimits(f_rated_l, f_max_l, f_rated_r,
f_max_r)) {
std::cout << " - 전방 드라이버 전류설정: 정격 L" << f_rated_l * 0.1f
<< "A/R" << f_rated_r * 0.1f << "A | 최대 L" << f_max_l * 0.1f
<< "A/R" << f_max_r * 0.1f << "A\n";
} else {
std::cout << "[경고] 전방 드라이버 전류설정 조회 실패\n";
}
if (driver_rear_ptr) {
uint16_t r_rated_l, r_max_l, r_rated_r, r_max_r;
if (driver_rear_ptr->readCurrentLimits(r_rated_l, r_max_l, r_rated_r,
r_max_r)) {
std::cout << " - 후방 드라이버 전류설정: 정격 L" << r_rated_l * 0.1f
<< "A/R" << r_rated_r * 0.1f << "A | 최대 L"
<< r_max_l * 0.1f << "A/R" << r_max_r * 0.1f << "A\n";
} else {
std::cout << "[경고] 후방 드라이버 전류설정 조회 실패\n";
}
}
if (max_current_a >= 0.0f) {
uint16_t new_max_01a = static_cast<uint16_t>(max_current_a * 10.0f);
driver_front.setMaxCurrent(new_max_01a);
if (driver_rear_ptr)
driver_rear_ptr->setMaxCurrent(new_max_01a);
std::cout << " - 최대전류를 " << max_current_a << "A로 낮춤(4바퀴 동일)\n";
}
}
std::cout << "\n[정보] C++ 100Hz 초저지연 루프를 시작합니다. (종료: Ctrl+C, "
"라이다 회피 " << (use_lidar ? "활성화" : "비활성화")
<< " 상태로 시작 - 변경하려면 --no_lidar 옵션을 사용하세요)\n\n";
float target_fl = 0.0f, target_fr = 0.0f;
float target_rl = 0.0f, target_rr = 0.0f;
float cmd_fl = 0.0f, cmd_fr = 0.0f;
float cmd_rl = 0.0f, cmd_rr = 0.0f;
// 각 바퀴의 현재 가속도 상태(저크 제한 프로파일용)
float accel_fl = 0.0f, accel_fr = 0.0f;
float accel_rl = 0.0f, accel_rr = 0.0f;
const float loop_hz = 100.0f;
const float dt = 1.0f / loop_hz;
// 가속: 역기전력 보호회로가 하드웨어로 구성되어 더 이상 소프트웨어로
// 완만하게 램프업할 필요가 없어져, 조이스틱 목표값에 1:1로 즉시 추종한다.
// 감속: 기존과 동일하게 저크 제한(jerk-limited) S-curve 유지 — 가속도
// 자체를 매 틱 jerk_limit 만큼만 변화시켜 서서히 목표 가속도에 도달하게
// 해서, 급감속 시 타이어 스크럽/전복 위험을 줄인다.
auto jerkLimitedStep = [dt](float current_vel, float target_vel,
float &current_accel, float max_decel_mag,
float jerk_limit) {
float diff = target_vel - current_vel;
if (std::abs(diff) < 0.01f) {
current_accel = 0.0f;
return target_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) {
current_accel = 0.0f;
return target_vel;
}
// 이번 틱에 diff를 없애는데 필요한 가속도(부호 포함)를 물리적 한계로 클램프
float desired_accel = std::clamp(diff / dt, -max_decel_mag, max_decel_mag);
float max_delta_accel = jerk_limit * dt;
current_accel = std::clamp(desired_accel, current_accel - max_delta_accel,
current_accel + max_delta_accel);
float next_vel = current_vel + current_accel * dt;
bool overshoot =
(diff > 0.0f) ? (next_vel >= target_vel) : (next_vel <= target_vel);
if (overshoot) {
current_accel = 0.0f;
return target_vel;
}
return next_vel;
};
std::string state = "STOPPED";
bool trip_initialized = false;
int32_t start_fl = 0, start_fr = 0, start_rl = 0, start_rr = 0;
// 바퀴별 이상 상태(직전 틱 피드백 기반 판정, 1틱=10ms 지연으로 반영)
bool airborne_fl = false, airborne_fr = false;
bool airborne_rl = false, airborne_rr = false;
int air_count_fl = 0, air_count_fr = 0, air_count_rl = 0, air_count_rr = 0;
// 착지(airborne 해제) 직후 한 틱 동안: 차체는 이미 그 속도로 움직이고
// 있는데 cmd가 뒤늦게 target을 쫓아가면 그 사이 바퀴가 지면 이동속도를
// 못 따라가 끌리며 긁히는 문제가 있어 즉시 target으로 스냅시켜 지연을
// 없앤다.
bool just_landed_fl = false, just_landed_fr = false;
bool just_landed_rl = false, just_landed_rr = false;
char wheel_status_str[5] = "OOOO";
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_rc_ok = false; // RC 연결 직전 틱 상태 (Fail-safe 로그용)
float omega_max = (effective_w > 0.0f) ? (max_spin_v / (effective_w / 2.0f))
: 0.0f; // CH1 조향 최대 각속도
// CRC 미검증으로 인해 손상된 CRSF 프레임이 드물게 통과할 수 있다.
// (실측 결과 이 송신기는 표준 CRC8 변형 어떤 것과도 안 맞아 — poly
// 전수조사+여러 바이트 범위로 확인해도 일치율이 최대 33% 수준이라
// CRC 자체를 신뢰할 수 없다고 판단, 검증 시도를 포기함.) 대신 두 가지
// 물리적 타당성 검사로 걸러낸다:
// 1) 캘리브레이션 범위(steer_min~max, throttle_min~max) 밖의 값 거부
// 2) 슬루레이트: 한 틱(~10ms) 사이에 사람이 스틱으로 낼 수 없을 만큼
// 크게 튀는 값 거부 (예: 1227 -> 1800 같은 순간 점프). 단, max_reject_streak
// 틱 연속으로 거부되면(=한 번의 손상 프레임이 아니라 실제 상황) 강제
// 수락해 재동기화한다 — 그렇지 않으면 last_raw_*가 그 자리에 영구히
// 고정되어, 이후 들어오는 진짜 값들도 계속 그 고정된 기준과 크게
// 차이나 버려 채널이 응답 없이 멈춰버린다.
// 범위 밖 값은 항상 거부, 그 틱은 마지막으로 유효했던 값을 그대로 사용한다.
int last_raw_steer = rc_cfg.steer_center_lo;
int last_raw_throttle = 1500;
bool steer_initialized = false, throttle_initialized = false;
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) {
// ---------------------------------------------------------------------
// RC 수신기 채널 읽기 (doc/joystick.md §2, §3 기능 명세서 기준)
// ---------------------------------------------------------------------
int raw_steer = rc_cfg.steer_center_lo, raw_throttle = 1500;
int raw_brake = 1011, raw_speed_mode = 1011;
bool rc_ok = rc.isConnected();
rc.getChannel(rc_cfg.ch_steer, raw_steer);
rc.getChannel(rc_cfg.ch_throttle, raw_throttle);
rc.getChannel(rc_cfg.ch_brake, raw_brake);
rc.getChannel(rc_cfg.ch_speed, raw_speed_mode);
bool steer_range_ok =
raw_steer >= rc_cfg.steer_min && raw_steer <= rc_cfg.steer_max;
if (!steer_range_ok) {
raw_steer = last_raw_steer; // 캘리브레이션 범위 밖 -> 항상 거부
} else {
bool steer_slew_ok = !steer_initialized ||
std::abs(raw_steer - last_raw_steer) <=
rc_cfg.max_slew_per_tick;
// slew 거부가 max_reject_streak틱 연속되면 진짜 빠른 스틱 조작으로
// 보고 강제로 재동기화한다 (아래 주석 참고: 그렇지 않으면 last_raw_*가
// 영구히 고정되어 채널이 멈춰버림).
if (steer_slew_ok || steer_reject_streak >= rc_cfg.max_reject_streak) {
last_raw_steer = raw_steer;
steer_initialized = true;
steer_reject_streak = 0;
} else {
raw_steer = last_raw_steer;
++steer_reject_streak;
}
}
bool throttle_range_ok = raw_throttle >= rc_cfg.throttle_min &&
raw_throttle <= rc_cfg.throttle_max;
if (!throttle_range_ok) {
raw_throttle = last_raw_throttle;
} else {
bool throttle_slew_ok = !throttle_initialized ||
std::abs(raw_throttle - last_raw_throttle) <=
rc_cfg.max_slew_per_tick;
if (throttle_slew_ok || throttle_reject_streak >= rc_cfg.max_reject_streak) {
last_raw_throttle = raw_throttle;
throttle_initialized = true;
throttle_reject_streak = 0;
} else {
raw_throttle = last_raw_throttle;
++throttle_reject_streak;
}
}
// CH1~CH8 raw 값 전체를 확인용으로 읽는다 (CH5=브레이크 값을 실시간으로
// 직접 눈으로 검증할 수 있도록 HUD/CSV에 그대로 노출).
int raw_ch[9]; // 인덱스 1..8 사용
for (int ch = 1; ch <= 8; ++ch) {
if (!rc.getChannel(ch, raw_ch[ch]))
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) {
std::cout << "\n[경고] RC 신호 Fail-safe 발동 (" << rc_cfg.failsafe_timeout_ms
<< "ms 이상 미수신) -> 비상 정지.\n";
}
prev_rc_ok = rc_ok;
// 3.1 브레이크 최우선 로직 (CH5) - 미수신(Fail-safe) 상태도 동일하게 처리
bool brake_on = !rc_ok || (raw_brake >= rc_cfg.brake_threshold);
// 3.2 속도 제한 설정 (CH6)
float v_max_now = (raw_speed_mode >= rc_cfg.speed_threshold)
? rc_cfg.v_max_low
: rc_cfg.v_max_high;
float v_x = 0.0f; // 선속도 m/s
float omega = 0.0f; // 각속도 rad/s
bool user_steer_centered = false; // Phase 3 헤딩 홀드 진입 조건용
if (!brake_on) {
// 3.3 전후진 속도 계산 (CH2) — 실측 결과 raw 값이 높을수록 전진(+),
// 낮을수록 후진(-)이라 CH1(조향)과는 반대 부호 방향이다.
int t = std::clamp(raw_throttle, rc_cfg.throttle_min, rc_cfg.throttle_max);
if (t >= rc_cfg.throttle_center_lo && t <= rc_cfg.throttle_center_hi) {
v_x = 0.0f;
} else if (t < rc_cfg.throttle_center_lo) {
v_x = -(static_cast<float>(rc_cfg.throttle_center_lo - t) /
static_cast<float>(rc_cfg.throttle_center_lo - rc_cfg.throttle_min)) *
v_max_now;
} else {
v_x = (static_cast<float>(t - rc_cfg.throttle_center_hi) /
static_cast<float>(rc_cfg.throttle_max - rc_cfg.throttle_center_hi)) *
v_max_now;
}
// 3.4 좌우 조향 각속도 계산 (CH1). omega 부호 규약(코드 전역 공통,
// 자동회피 로직의 "+omega=좌회전/-omega=우회전" 주석과 일치): 스틱을
// 왼쪽(raw 낮음)으로 밀면 +omega(좌회전), 오른쪽(raw 높음)이면
// -omega(우회전)여야 한다. 예전엔 부호가 반대로 들어가 있어서 스틱
// 방향과 실제 회전 방향이 뒤바뀌어 있었다.
int s = std::clamp(raw_steer, rc_cfg.steer_min, rc_cfg.steer_max);
if (s >= rc_cfg.steer_center_lo && s <= rc_cfg.steer_center_hi) {
omega = 0.0f;
} else if (s < rc_cfg.steer_center_lo) {
omega = (static_cast<float>(rc_cfg.steer_center_lo - s) /
static_cast<float>(rc_cfg.steer_center_lo - rc_cfg.steer_min)) *
omega_max;
} else {
omega = -(static_cast<float>(s - rc_cfg.steer_center_hi) /
static_cast<float>(rc_cfg.steer_max - rc_cfg.steer_center_hi)) *
omega_max;
}
user_steer_centered = (omega == 0.0f);
}
// ---------------------------------------------------------------------
// LiDAR Obstacle Avoidance & Stopping Logic Integration
// (요청에 따라 후진 시 측면 회피는 미적용 - 전진 시에만 좌우 회피 동작)
// ---------------------------------------------------------------------
std::string lidar_telemetry = "LIDAR:OFF";
float avoid_omega_offset = 0.0f; // Phase 3 헤딩 홀드가 라이다 회피와
// 충돌하지 않도록 블록 밖에서도 확인
if (use_lidar) {
ObstacleStatus obs = lidar_detector.getStatus();
if (obs.connected) {
float speed_scale = 1.0f;
// 1. Forward / Backward Automatic Stopping
if (v_x > 0.01f) {
if (obs.min_dist_front < front_stop_dist) {
speed_scale = 0.0f; // Complete forward stop
} else if (obs.min_dist_front < front_warn_dist) {
speed_scale = (obs.min_dist_front - front_stop_dist) /
(front_warn_dist - front_stop_dist);
speed_scale = std::clamp(speed_scale, 0.0f, 1.0f);
}
} else if (v_x < -0.01f) {
if (obs.min_dist_rear < front_stop_dist) {
speed_scale = 0.0f; // Complete backward stop
}
}
// 2. Side Dodge Steering (전진 중, 사용자 조향 입력이 없을 때만 동작)
if (v_x > 0.01f && omega == 0.0f) {
float right_intensity = 0.0f;
float left_intensity = 0.0f;
if (obs.min_dist_right < side_dodge_dist) {
right_intensity = (side_dodge_dist - obs.min_dist_right) /
(side_dodge_dist - side_stop_dist);
right_intensity = std::clamp(right_intensity, 0.0f, 1.0f);
}
if (obs.min_dist_left < side_dodge_dist) {
left_intensity = (side_dodge_dist - obs.min_dist_left) /
(side_dodge_dist - side_stop_dist);
left_intensity = std::clamp(left_intensity, 0.0f, 1.0f);
}
// Right obstacle -> Steer Left (+omega)
// Left obstacle -> Steer Right (-omega)
avoid_omega_offset =
(right_intensity - left_intensity) * max_dodge_omega;
}
v_x *= speed_scale;
char lbuf[128];
snprintf(lbuf, sizeof(lbuf), "F:%4.2fm|L:%4.2fm|R:%4.2fm %s",
obs.min_dist_front > 99.0f ? 9.99f : obs.min_dist_front,
obs.min_dist_left > 99.0f ? 9.99f : obs.min_dist_left,
obs.min_dist_right > 99.0f ? 9.99f : obs.min_dist_right,
avoid_omega_offset > 0.05f
? "[Dodge L]"
: (avoid_omega_offset < -0.05f
? "[Dodge R]"
: (speed_scale < 0.01f ? "[STOP!]" : "[OK]")));
lidar_telemetry = lbuf;
// Add avoidance offset to user steering
omega += avoid_omega_offset;
} else {
lidar_telemetry = "LIDAR:WAITING";
}
}
// ---------------------------------------------------------------------
// 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)이면서
// CH1(조향) 입력이 있을 때만 진입한다. v_x는 데드존 처리 시 정확히
// 0.0f로 설정되므로 등호 비교가 안전하다.
bool is_spin_turn = (v_x == 0.0f) && (std::abs(omega) > 0.0f);
if (is_spin_turn) {
// 제자리 회전 (Spin Turn) + 최소 회전반경용 미세 곡선 혼합 (스크럽
// 마찰 저항 감소를 위해 ICR을 로봇 중심에서 살짝 벗어나게 함)
float norm = (omega_max > 0.0f)
? std::clamp(std::abs(omega) / omega_max, 0.0f, 1.0f)
: 0.0f;
float v_bias = norm * max_spin_v * spin_arc_bias;
float v_l = v_bias - omega * (effective_w / 2.0f);
float v_r = v_bias + omega * (effective_w / 2.0f);
target_fl = v_l * rpm_per_ms;
target_fr = -v_r * rpm_per_ms;
target_rl = v_l * rpm_per_ms;
target_rr = -v_r * rpm_per_ms;
} else {
// 직진 및 차동 선회 Kinematics (CH1 조향 + 라이다 회피 오프셋 포함)
float v_l = v_x - omega * k_skid;
float v_r = v_x + omega * k_skid;
target_fl = v_l * rpm_per_ms;
target_fr = -v_r * rpm_per_ms;
target_rl = v_l * rpm_per_ms;
target_rr = -v_r * rpm_per_ms;
}
// 직전 틱 피드백에서 무부하(들뜸)로 판정된 바퀴는 목표 속도를 0으로 낮춰
// 헛돌이를 억제한다. 접지력이 회복되어 전류가 정상으로 돌아오면 다음
// 판정 틱에서 자동으로 해제되어 원래 지령으로 복귀한다.
// 단, 제자리 회전 중에는 반력 토크로 인한 대각선 하중 이동(FL+RR 또는
// FR+RL 쌍이 동시에 가벼워짐)이 정상적인 현상인데 이걸 "들뜸"으로 오判定해
// 목표 속도를 꺼버리면 4륜 중 사실상 2륜만 구동되어 회전력이 반토막
// 난다(실측 로그에서 전체 회전 틱의 38%가 대각선 쌍 동시 들뜸 판정이었고,
// 그 결과 실제 회전율이 지령 대비 44~58%에 그쳤음). 그래서 제자리 회전
// 중에는 판정(HUD 표시용)은 유지하되 속도를 꺾는 개입만 끈다.
if (!is_spin_turn) {
if (airborne_fl)
target_fl = 0.0f;
if (airborne_fr)
target_fr = 0.0f;
if (airborne_rl)
target_rl = 0.0f;
if (airborne_rr)
target_rr = 0.0f;
}
// 3.1 브레이크 최우선 로직 (CH5/Fail-safe): 저크 램프를 건너뛰고 즉시
// 0으로 스냅 후 브레이크를 잠근다. 해제되면 STOPPED 상태에서 아래
// 상태머신이 정상적으로 재가속을 시작한다.
// 전자식 브레이크(솔레노이드)는 오직 CH5로만 제어한다 — 스로틀을 중립으로
// 되돌려 자연 정지했을 때는 잠그지 않고, CH5를 눌렀을 때만 잠근다.
if (brake_on && !prev_brake_on) {
cmd_fl = cmd_fr = cmd_rl = cmd_rr = 0.0f;
accel_fl = accel_fr = accel_rl = accel_rr = 0.0f;
driver_front.setRPMs(0, 0);
if (driver_rear_ptr)
driver_rear_ptr->setRPMs(0, 0);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
driver_front.setBrakes(true);
if (driver_rear_ptr)
driver_rear_ptr->setBrakes(true);
state = "STOPPED";
} else if (!brake_on && prev_brake_on) {
driver_front.setBrakes(false);
if (driver_rear_ptr)
driver_rear_ptr->setBrakes(false);
}
prev_brake_on = brake_on;
if (brake_on) {
// target_fl..rr은 이미 0(v_x=omega=0)이며 STOPPED 상태를 유지한다.
} else if (state == "STOPPED") {
if (std::abs(target_fl) > 0.1f || std::abs(target_fr) > 0.1f) {
state = "RUNNING";
}
} else if (state == "RUNNING") {
// 착지 직후: 정지출발용 저크 램프를 건너뛰고 차체가 이미 내고 있는
// 속도로 즉시 맞춰 지면과의 미끄러짐(끌림)을 최소화한다.
if (just_landed_fl) {
cmd_fl = target_fl;
accel_fl = 0.0f;
just_landed_fl = false;
}
if (just_landed_fr) {
cmd_fr = target_fr;
accel_fr = 0.0f;
just_landed_fr = false;
}
if (just_landed_rl) {
cmd_rl = target_rl;
accel_rl = 0.0f;
just_landed_rl = false;
}
if (just_landed_rr) {
cmd_rr = target_rr;
accel_rr = 0.0f;
just_landed_rr = false;
}
float jerk_now = is_spin_turn ? spin_jerk_rate : jerk_rate;
cmd_fl = jerkLimitedStep(cmd_fl, target_fl, accel_fl, decel_rate, jerk_now);
cmd_fr = jerkLimitedStep(cmd_fr, target_fr, accel_fr, decel_rate, jerk_now);
cmd_rl = jerkLimitedStep(cmd_rl, target_rl, accel_rl, decel_rate, jerk_now);
cmd_rr = jerkLimitedStep(cmd_rr, target_rr, accel_rr, decel_rate, jerk_now);
if (std::abs(target_fl) < 0.1f && std::abs(target_fr) < 0.1f &&
std::abs(cmd_fl) < 0.5f && std::abs(cmd_fr) < 0.5f) {
state = "STOPPING";
}
} else if (state == "STOPPING") {
float jerk_now = is_spin_turn ? spin_jerk_rate : jerk_rate;
cmd_fl = jerkLimitedStep(cmd_fl, target_fl, accel_fl, decel_rate, jerk_now);
cmd_fr = jerkLimitedStep(cmd_fr, target_fr, accel_fr, decel_rate, jerk_now);
cmd_rl = jerkLimitedStep(cmd_rl, target_rl, accel_rl, decel_rate, jerk_now);
cmd_rr = jerkLimitedStep(cmd_rr, target_rr, accel_rr, decel_rate, jerk_now);
if (std::abs(cmd_fl) < 0.1f && std::abs(cmd_fr) < 0.1f) {
cmd_fl = 0.0f;
cmd_fr = 0.0f;
cmd_rl = 0.0f;
cmd_rr = 0.0f;
accel_fl = accel_fr = accel_rl = accel_rr = 0.0f;
driver_front.setRPMs(0, 0);
if (driver_rear_ptr)
driver_rear_ptr->setRPMs(0, 0);
// 스로틀 중립 복귀로 인한 자연 정지: 전자식 브레이크는 잠그지 않는다
// (CH5를 눌렀을 때만 잠금 — 위 브레이크 최우선 로직 참고).
state = "STOPPED";
}
}
auto comm_start = std::chrono::high_resolution_clock::now();
if (state != "STOPPED") {
driver_front.setRPMs(cmd_fl, cmd_fr);
if (driver_rear_ptr)
driver_rear_ptr->setRPMs(cmd_rl, cmd_rr);
}
float fl_fb = 0, fr_fb = 0, rl_fb = 0, rr_fb = 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;
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,
err_f_l, err_f_r, fl_temp, fr_temp);
if (driver_rear_ptr)
driver_rear_ptr->readFeedback(rl_fb, rr_fb, rl_tick, rr_tick, rl_amp,
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 줄과 별개의 고정 줄).
// 알람이 뜨면 드라이버가 명령을 무시하는 잠금 상태가 되어 소프트웨어
// 재시작만으로는 안 풀리는 경우가 많다(하드웨어 클리어/재활성화 필요).
bool fault_now = err_f_l || err_f_r || err_r_l || err_r_r;
if (fault_now && !fault_active) {
std::cout << "\n\n[!!! 드라이버 알람 발생 !!!] "
<< "전방L:" << decodeDriverError(err_f_l)
<< " 전방R:" << decodeDriverError(err_f_r)
<< " 후방L:" << decodeDriverError(err_r_l)
<< " 후방R:" << decodeDriverError(err_r_r)
<< " -> 모터 정지, 전원 재시작 또는 알람클리어 필요\n\n";
}
fault_active = fault_now;
auto comm_end = std::chrono::high_resolution_clock::now();
float comm_ms =
std::chrono::duration<float, std::milli>(comm_end - comm_start).count();
// 바퀴별 이상(들뜸/걸림) 판정: 속도 폐루프 특성상 "지령 대비 실제속도"
// 만으로는 무부하(들뜸)를 구분할 수 없다 (드라이버가 부하와 무관하게
// 지령 RPM을 그대로 추종하려 하므로). 그래서 두 축으로 판정한다.
// - 걸림/과부하: 실제속도가 지령을 크게 못 따라가면서 전류가 높음 (독립 판정)
// - 무부하/들뜸: "같은 지령속도를 받는 짝(앞뒤 같은 쪽)" 대비 전류가
// 비정상적으로 낮음. 좌/우를 통째로 비교하면 정상적인 커브 선회에서
// 지령속도가 원래 다른 좌우 바퀴를 오탐하므로, 반드시 target_fl=target_rl,
// target_fr=target_rr 로 지령이 항상 같은 같은쪽 앞뒤 쌍끼리만 비교한다.
auto classifyStall = [&](float cmd_rpm, float actual_rpm,
float amp) -> bool {
float cmd_abs = std::abs(cmd_rpm);
if (cmd_abs < min_active_rpm)
return false;
float vel_ratio = std::abs(actual_rpm) / cmd_abs;
return vel_ratio < stall_vel_ratio && std::abs(amp) >= stall_current_a;
};
bool stall_fl = classifyStall(cmd_fl, fl_fb, fl_amp);
bool stall_fr = classifyStall(cmd_fr, fr_fb, fr_amp);
bool stall_rl = classifyStall(cmd_rl, rl_fb, rl_amp);
bool stall_rr = classifyStall(cmd_rr, rr_fb, rr_amp);
bool air_now_fl = false, air_now_rl = false;
bool air_now_fr = false, air_now_rr = false;
auto classifyAirPair = [&](float cmd_rpm, float amp_a, float amp_b,
bool &air_a, bool &air_b) {
if (std::abs(cmd_rpm) < min_active_rpm)
return; // 정지/저속 중이면 판정 보류
float a = std::abs(amp_a), b = std::abs(amp_b);
float hi = std::max(a, b);
if (hi < 0.5f)
return; // 둘 다 거의 무전류(관성 주행 등)면 판정 보류
if (a < hi * airborne_current_ratio)
air_a = true;
if (b < hi * airborne_current_ratio)
air_b = true;
};
classifyAirPair(cmd_fl, fl_amp, rl_amp, air_now_fl, air_now_rl);
classifyAirPair(cmd_fr, fr_amp, rr_amp, air_now_fr, air_now_rr);
// 디바운스: 노이즈성 순간 전류 편차로 목표속도가 즉시 0으로 깎이지 않도록
// 연속 airborne_debounce_ticks 틱 이상 지속될 때만 실제로 개입한다.
air_count_fl = air_now_fl ? air_count_fl + 1 : 0;
air_count_fr = air_now_fr ? air_count_fr + 1 : 0;
air_count_rl = air_now_rl ? air_count_rl + 1 : 0;
air_count_rr = air_now_rr ? air_count_rr + 1 : 0;
bool prev_airborne_fl = airborne_fl;
bool prev_airborne_fr = airborne_fr;
bool prev_airborne_rl = airborne_rl;
bool prev_airborne_rr = airborne_rr;
airborne_fl = air_count_fl >= airborne_debounce_ticks;
airborne_fr = air_count_fr >= airborne_debounce_ticks;
airborne_rl = air_count_rl >= airborne_debounce_ticks;
airborne_rr = air_count_rr >= airborne_debounce_ticks;
// 방금 착지(뜬 상태 -> 정상)한 바퀴는 다음 틱에 cmd를 target으로 즉시
// 스냅해 재가속 지연을 없앤다.
if (prev_airborne_fl && !airborne_fl)
just_landed_fl = true;
if (prev_airborne_fr && !airborne_fr)
just_landed_fr = true;
if (prev_airborne_rl && !airborne_rl)
just_landed_rl = true;
if (prev_airborne_rr && !airborne_rr)
just_landed_rr = true;
auto statusChar = [](bool air, bool stall) {
return air ? 'A' : (stall ? 'S' : 'O');
};
wheel_status_str[0] = statusChar(airborne_fl, stall_fl);
wheel_status_str[1] = statusChar(airborne_fr, stall_fr);
wheel_status_str[2] = statusChar(airborne_rl, stall_rl);
wheel_status_str[3] = statusChar(airborne_rr, stall_rr);
float meters_per_tick = (2.0f * PI_VAL * wheel_radius) / 16384.0f;
if (!trip_initialized && (fl_tick != 0 || fr_tick != 0)) {
start_fl = fl_tick;
start_fr = fr_tick;
start_rl = rl_tick;
start_rr = rr_tick;
trip_initialized = true;
}
int32_t delta_fl = std::abs(fl_tick - start_fl);
int32_t delta_fr = std::abs(fr_tick - start_fr);
int32_t delta_rl = std::abs(rl_tick - start_rl);
int32_t delta_rr = std::abs(rr_tick - start_rr);
float dist_fl = delta_fl * meters_per_tick;
float dist_fr = delta_fr * meters_per_tick;
float dist_rl = delta_rl * meters_per_tick;
float dist_rr = delta_rr * meters_per_tick;
// 중앙값(4개 중 가운데 2개 평균) 기반 주행거리 산출: 웅덩이 등으로 바퀴
// 하나가 지면에서 떨어져 엔코더만 헛돌 때, 그 이상치 값이 평균(mean)을
// 오염시키지 않도록 최댓값/최솟값을 자동으로 배제함.
float dist_sorted[4] = {dist_fl, dist_fr, dist_rl, dist_rr};
std::sort(dist_sorted, dist_sorted + 4);
float dist_axle = (dist_sorted[1] + dist_sorted[2]) / 2.0f;
float dist_bumper = dist_axle + (dist_axle > 0.001f ? bumper_offset : 0.0f);
(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()) {
float t_ms = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - log_start_time)
.count();
log_stream << t_ms << ',' << state << ',' << raw_steer << ','
<< raw_throttle << ',' << (rc_ok ? 1 : 0) << ','
<< (brake_on ? 1 : 0) << ',' << v_max_now << ',' << v_x << ','
<< omega << ',' << raw_ch[1] << ',' << raw_ch[2] << ','
<< raw_ch[3] << ',' << raw_ch[4] << ',' << raw_ch[5] << ','
<< 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_rr << ',' << fl_fb << ',' << fr_fb << ','
<< rl_fb << ',' << rr_fb << ',' << fl_amp << ',' << fr_amp
<< ',' << 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[3] << ',' << err_f_l << ','
<< 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';
}
// RC 조종기 입력값 피드백: 실기 조작 중 CH1~CH8 raw 값(CH5=브레이크 값
// 직접 확인용 포함)과 그로부터 계산된 선속도/각속도 지령을 실시간으로
// 확인할 수 있도록 HUD에 표시한다.
const char *speed_mode_str =
(v_max_now <= rc_cfg.v_max_low + 0.001f) ? "LOW " : "HIGH";
printf("\r\033[K[%-8s] RC:%s%s "
"C1:%4d C2:%4d C3:%4d C4:%4d C5:%4d C6:%4d C7:%4d C8:%4d "
"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 | "
"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",
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[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,
rl_amp, rr_amp, fl_temp, fr_temp, rl_temp, rr_temp,
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);
std::this_thread::sleep_for(
std::chrono::microseconds(static_cast<int>(dt * 1000000)));
}
std::cout << "\n\n[정보] C++ 4WD 안전 정지 및 브레이크 잠금 처리 중...\n";
driver_front.setRPMs(0, 0);
if (driver_rear_ptr)
driver_rear_ptr->setRPMs(0, 0);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
driver_front.setBrakes(true);
if (driver_rear_ptr)
driver_rear_ptr->setBrakes(true);
if (use_lidar)
lidar_detector.stop();
if (log_stream.is_open()) {
log_stream.close();
std::cout << "[정보] CSV 로그 저장 완료: " << log_path << "\n";
}
rc.stop();
if (use_imu)
imu.stop();
std::cout << "[정보] C++ 4WD 제어 프로그램이 성공적으로 종료되었습니다.\n";
return 0;
}