4 Commits

Author SHA1 Message Date
robin d5a47881ac Fix RC steering sign inversion and throttle/steer slew-filter livelock
Steering (CH1): omega was computed with the opposite sign from the
convention used everywhere else in this file (+omega=left,
-omega=right, per the LiDAR avoidance comments and the prior Xbox
joystick code), so pushing the stick left steered the robot right
and vice versa. Flipped the sign at the single point omega is
derived from the stick so curve-turn and spin-turn both inherit the
fix.

Throttle/steer anti-corruption filter: once a reading was rejected
for exceeding max_slew_per_tick, last_raw_throttle/last_raw_steer
never updated, so every subsequent real reading kept failing the
same slew check against that now-permanently-stale reference —
a livelock. Symptom: CH2 showing neutral (1500) on the HUD while the
robot kept driving forward regardless of stick input. Added a
max_reject_streak escape hatch: after a few consecutive rejections
in a row, treat it as a real fast stick movement rather than a
one-off corrupted frame and resync to the latest value.

Verified working on hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 15:30:16 +09:00
robin dec1d85f9b change the controler 2026-08-19 14:44:25 +09:00
robin 6be30eb929 add doc 2026-08-19 13:56:22 +09:00
robin bb5a0adc0c Replace Xbox joystick control with RadioMaster Pocket + XR1 RC receiver
Migrates the 4WD control input from SDL2/Xbox gamepad to a serial RC
receiver per doc/joystick.md's functional spec: CH1 steering, CH2
throttle, CH5 emergency brake (highest priority), CH7 speed mode
(0.3/0.6 m/s), with a 200ms fail-safe that forces an immediate stop
on signal loss. Channel mapping, deadzones, port, and baudrate are
all configurable via --rc_* CLI flags. LiDAR avoidance, jerk-limited
motion profile, and wheel-fault detection are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 12:44:51 +09:00
6 changed files with 772 additions and 160 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
CXX = g++
CXXFLAGS = -O3 -std=c++17 -Wall -Wextra -I/usr/local/include
LIBS = -lSDL2 -lpthread -llivox_lidar_sdk_shared
LIBS = -lpthread -llivox_lidar_sdk_shared
TARGET = xbox_motor_control_cpp
SRC = xbox_motor_control.cpp
+67
View File
@@ -0,0 +1,67 @@
# [기능 명세서] RadioMaster Pocket & XR1 V1.0 기반 조종 제어 전환
## 1. 프로젝트 개요
* **목적:** 기존 Xbox 게임패드 기반의 원격 제어 모듈을 **RadioMaster Pocket RC 조종기****XR1 V1.0 수신기(USB-to-TTL 시리얼 통신)** 기반으로 교체/마이그레이션.
* **통신 환경:**
* 하드웨어: USB to TTL 컨버터 (`/dev/ttyUSB*` 또는 COM 포트)
* 수신기: XR1 V1.0
* 조종기: RadioMaster Pocket
* **실측 확인된 시리얼 프로토콜: CRSF(Crossfire), 420000bps, 8N1.**
프레임 구조 `[Sync 0xC8][Length][FrameType][Payload][CRC8]`, FrameType
`0x16`(RC_CHANNELS_PACKED)의 22바이트 payload에 16채널이 11비트씩
리틀엔디안으로 패킹되어 raw `172~1811` 값이 `1000~2000us`로 선형
변환된다. (CRC8은 이 송신기에서 표준 DVB-S2 값과 안 맞아 검증 없이
Sync+Length 프레이밍만 신뢰함.)
---
## 2. 하드웨어 및 채널 매핑 사양
### 2.1 채널별 입력 범위 및 기능 정의
| 채널 | 입력 장치 | Raw Value 범위 | 할당 기능 | 동작 설명 |
| :--- | :--- | :--- | :--- | :--- |
| **CH1** | 롤/조향 스틱 | • `1005`: 좌측 끝<br>• `1481 ~ 1503`: 중앙 (데드존)<br>• `2000`: 우측 끝 | **좌우 조향 (Steering / Angular Z)** | • 중앙 데드존(`1481~1503`) 적용<br>• 좌회전(-) / 우회전(+) 정규화 |
| **CH2** | 피치/스로틀 스틱 | • `1005`: 최대 후진<br>• `1500`: 중앙 (정지)<br>• `2000`: 최대 전진 | **전후진 (Throttle / Linear X)** | • 값이 높을수록 전진, 낮을수록 후진 (CH1과 반대 방향)<br>• CH6에서 결정된 최대 속도 기준으로 선형 매핑 |
| **CH5** | 2단 스위치 | • `1012`: 미작동 (OFF)<br>• `1988`: 눌림 (ON) | **전자/비상 브레이크 (Brake)** | • `1988` 수신 시 모든 주행 속도 즉시 0 (`Stop`)<br>• `1012` 수신 시 일반 주행 허용 |
| **CH6** | 2단 스위치 | • `1832`: 저속 위치<br>• `1012~1090`: 고속 위치 | **속도 모드 선택 (Speed Mode)** | • `1832`(≥1500 부근): 저속 모드 (최대 **0.3 m/s**)<br>• `1012~1090`(<1500): 고속 모드 (최대 **1.5 m/s**) |
---
## 3. 제어 변환 알고리즘 및 로직
### 3.1 브레이크 최우선 로직 (CH5)
* $\text{CH5} \ge 1500$ (누름 / `1988` 부근):
$$\text{Linear Velocity} = 0.0, \quad \text{Angular Velocity} = 0.0$$
* $\text{CH5} < 1500$ (안누름 / `1012` 부근): 정상 주행 로직 실행
### 3.2 속도 제한 설정 (CH6)
* $\text{CH6} \ge 1500$ (`1832` 부근): $V_{\max} = 0.3 \text{ m/s}$ (저속 모드)
* $\text{CH6} < 1500$ (`1012~1090` 부근): $V_{\max} = 1.5 \text{ m/s}$ (고속 모드)
### 3.3 전후진 속도 계산 (CH2)
* **데드존(Deadband):** $1480 \le \text{CH2} \le 1520 \rightarrow V_x = 0.0$
* **후진 구간 ($1005 \le \text{CH2} < 1480$):**
$$V_x = -\left( \frac{1480 - \text{CH2}}{1480 - 1005} \right) \times V_{\max}$$
* **전진 구간 ($1520 < \text{CH2} \le 2000$):**
$$V_x = \left( \frac{\text{CH2} - 1520}{2000 - 1520} \right) \times V_{\max}$$
### 3.4 좌우 조향 각속도 계산 (CH1)
* **데드존(Deadband):** $1481 \le \text{CH1} \le 1503 \rightarrow \omega_z = 0.0$
* **좌회전 구간 ($1005 \le \text{CH1} < 1481$):**
$$\omega_z = -\left( \frac{1481 - \text{CH1}}{1481 - 1005} \right) \times \Omega_{\max}$$
* **우회전 구간 ($1503 < \text{CH1} \le 2000$):**
$$\omega_z = +\left( \frac{\text{CH1} - 1503}{2000 - 1503} \right) \times \Omega_{\max}$$
---
## 4. 구현 요구사항 (To Claude Code)
1. **기존 Xbox 입력 모듈 분리 및 교체:**
- 기존의 조이스틱 라이브러리(pygame, evdev, joy 노드 등) 종속성 제거
- 시리얼 통신(PySerial 등) 기반의 RC 수신기 데이터 리더 클래스 구현
2. **시리얼 패킷 디코딩 & Fail-safe 처리:**
- USB-to-TTL 시리얼 포트 오픈 및 예외 처리(재연결 로직)
- 패킷 타임아웃(예: 200ms 이상 신호 미수신 시) 발생 시 자동 감속 및 긴급 정지
3. **설정값 파라미터화 (Config):**
- Serial Port, Baudrate, 데드존 범위, 채널별 Max/Min/Center 값을 외부 설정(YAML, JSON, ROS 파라미터 등)으로 관리 가능하도록 모듈화
+79
View File
@@ -0,0 +1,79 @@
import serial
import time
# 확인된 포트 경로 설정
SERIAL_PORT = "/dev/cu.usbserial-0001"
BAUD_RATE = 420000
CRSF_SYNC = 0xC8
CRSF_FRAMETYPE_RC_CHANNELS_PACKED = 0x16
def parse_channels(payload):
if len(payload) < 22:
return []
# 22바이트의 채널 데이터를 정수로 변환 후 11비트씩 언패킹
data = int.from_bytes(payload[:22], byteorder='little')
channels = []
for _ in range(16):
raw_val = data & 0x07FF # 11-bit 마스크
# CRSF 원본 값(172~1811)을 RC 마이크로초(1000~2000µs) 단위로 변환
us_val = int((raw_val - 172) * (2000 - 1000) / (1811 - 172) + 1000)
channels.append(us_val)
data >>= 11
return channels
def main():
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=0.1)
print(f"Connected to {SERIAL_PORT} at {BAUD_RATE} baud.")
print("스틱과 스위치를 움직여 채널 값을 확인하세요. (종료: Ctrl+C)\n")
except Exception as e:
print(f"포트 연결 오류: {e}")
return
buf = bytearray()
while True:
try:
if ser.in_waiting:
buf.extend(ser.read(ser.in_waiting))
while len(buf) > 3:
if buf[0] != CRSF_SYNC:
buf.pop(0)
continue
length = buf[1]
total_packet_len = length + 2
if len(buf) < total_packet_len:
break # 전체 패킷 대기
packet = buf[:total_packet_len]
buf = buf[total_packet_len:]
frame_type = packet[2]
if frame_type == CRSF_FRAMETYPE_RC_CHANNELS_PACKED:
payload = packet[3:-1] # CRC 제외 payload
channels = parse_channels(payload)
# read_crsf.py 출력 부분 수정
print(
f"\rCH1: {channels[0]:4d} | CH2: {channels[1]:4d} | "
f"CH3: {channels[2]:4d} | CH4: {channels[3]:4d} | "
f"CH5(SA/SC): {channels[4]:4d} | CH6(SD): {channels[5]:4d} | "
f"CH7: {channels[6]:4d} | CH8: {channels[7]:4d}",
end="", flush=True
)
except KeyboardInterrupt:
print("\n모니터링 종료")
break
except Exception as e:
print(f"\n데이터 수신 오류: {e}")
break
ser.close()
if __name__ == "__main__":
main()
+14 -9
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
# 1-Click Launch Script for C++ 4WD Motor Control with Mid-360S LiDAR Avoidance
PORT="${1:-/dev/ttyUSB0}"
# 현재 고정 배선: 모터 드라이버 = ttyUSB1, RC 수신기(XR1) = ttyUSB0
PORT="${1:-/dev/ttyUSB1}"
RC_PORT="${RC_PORT:-/dev/ttyUSB0}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" || exit 1
@@ -10,10 +12,11 @@ echo "=================================================================="
echo " 🚀 ZLAC8015D 4WD + Livox Mid-360S 라이다 원클릭 런치 시스템"
echo "=================================================================="
# 1. USB Latency 1ms 단축
# 1. USB Latency 1ms 단축 (모터 포트 + RC 수신기 포트 둘 다)
if [ -f "./set_low_latency.sh" ]; then
echo "[1/2] USB 시리얼 포트($PORT) 지연 시간 1ms 최적화 적용 중..."
echo "[1/2] USB 시리얼 포트 지연 시간 1ms 최적화 적용 중... (모터: $PORT / RC: $RC_PORT)"
./set_low_latency.sh "$PORT"
./set_low_latency.sh "$RC_PORT"
fi
# 2. C++ 바이너리 존재 여부 확인 및 컴파일
@@ -23,14 +26,16 @@ if [ ! -f "./xbox_motor_control_cpp" ]; then
fi
echo "=================================================================="
echo " [시작] C++ 100Hz 초저지연 4WD 조이스틱 + 라이다 장애물 회피 시작 ($PORT)"
echo " - 라이다 기능 토글: Xbox 컨트롤러 X 버튼"
echo " - 정지/종료: B 버튼 또는 Ctrl+C"
echo " [시작] C++ 100Hz 초저지연 4WD RC(RadioMaster Pocket + XR1) + 라이다 장애물 회피 시작"
echo " - 모터 포트: $PORT / RC 수신기 포트: $RC_PORT"
echo " - 라이다 기능 비활성화: --no_lidar 옵션"
echo " - 비상 정지: CH5 브레이크 스위치 또는 Ctrl+C"
echo "=================================================================="
# 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능)
# 3. C++ 4WD 프로그램 실행 (추가 인자 전달 가능. --rc_port를 다시 넘기면
# 아래 기본값을 덮어쓸 수 있다 — 인자 파싱은 뒤에 온 값이 우선 적용됨)
if [ $# -gt 1 ]; then
./xbox_motor_control_cpp --port "$PORT" "${@:2}"
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT" "${@:2}"
else
./xbox_motor_control_cpp --port "$PORT"
./xbox_motor_control_cpp --port "$PORT" --rc_port "$RC_PORT"
fi
+611 -150
View File
@@ -1,12 +1,14 @@
#include <SDL2/SDL.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <csignal>
#include <fcntl.h>
#include <fstream>
#include <iostream>
#include <map>
#include <mutex>
#include <string>
#include <sys/ioctl.h>
@@ -601,34 +603,337 @@ public:
LidarObstacleDetector *LidarObstacleDetector::instance_ = nullptr;
float applyDeadzone(float val, float deadzone = 0.1f) {
if (std::abs(val) < deadzone)
return 0.0f;
float sign = (val > 0.0f) ? 1.0f : -1.0f;
float norm = (std::abs(val) - deadzone) / (1.0f - deadzone);
return sign * norm;
}
// -----------------------------------------------------------------------------
// 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 {
std::string port = "/dev/ttyUSB0"; // 현재 고정 배선: RC 수신기(XR1) = ttyUSB0
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));
}
}
}
};
int main(int argc, char **argv) {
std::signal(SIGINT, signalHandler);
std::signal(SIGTERM, signalHandler);
std::string port1 = "/dev/ttyUSB0";
std::string port1 = "/dev/ttyUSB1"; // 현재 고정 배선: 모터 드라이버 = ttyUSB1
std::string port2 = "";
uint8_t id1 = 1;
uint8_t id2 = 2;
bool bcast_mode = false;
float max_v = 0.40f; // m/s
RcConfig rc_cfg;
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 accel_rate = 120.0f; // RPM/s (최대 가속도 한계)
// 가속은 역기전력 보호회로가 하드웨어로 구성되어 목표값에 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 (제자리 회전 저크 한계: 고마찰 노면에서
// 정지마찰이 급격히 파괴되지 않도록 accel_rate에
// 도달하는 시간을 더 길게 늘림)
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을 로봇 중심에서
// 살짝 벗어나게 해 스크럽 마찰 저항을 줄임)
@@ -690,8 +995,6 @@ int main(int argc, char **argv) {
wheel_radius = std::stof(argv[++i]);
else if (arg == "--bumper" && i + 1 < argc)
bumper_offset = std::stof(argv[++i]);
else if (arg == "--accel" && i + 1 < argc)
accel_rate = std::stof(argv[++i]);
else if (arg == "--decel" && i + 1 < argc)
decel_rate = std::stof(argv[++i]);
else if (arg == "--jerk" && i + 1 < argc)
@@ -738,6 +1041,50 @@ int main(int argc, char **argv) {
max_z = std::stof(argv[++i]);
else if (arg == "--no_lidar")
use_lidar = 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]);
}
constexpr float PI_VAL = 3.14159265358979323846f;
@@ -761,10 +1108,16 @@ int main(int argc, char **argv) {
std::cout << " - 필터링 범위: 높이 Z(" << min_z << "m ~ " << max_z
<< "m) | 회피 정밀 전폭 " << (robot_width + 0.10f) * 1000.0f
<< "mm\n";
std::cout << " - 최고 속도: " << max_v << "m/s | 가속: " << accel_rate
<< "RPM/s | Jerk: " << jerk_rate << "RPM/s² (제자리회전: "
<< spin_jerk_rate << "RPM/s²) | 회전 곡선혼합: "
<< spin_arc_bias * 100.0f << "%\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";
@@ -783,7 +1136,8 @@ int main(int argc, char **argv) {
if (!log_path.empty()) {
log_stream.open(log_path);
if (log_stream.is_open()) {
log_stream << "t_ms,state,ly,lx,rx,"
log_stream << "t_ms,state,rc_steer,rc_throttle,rc_ok,brake,vmax,vx,omega,"
"ch1,ch2,ch3,ch4,ch5,ch6,ch7,ch8,"
"cmd_fl,cmd_fr,cmd_rl,cmd_rr,"
"fb_fl,fb_fr,fb_rl,fb_rr,"
"amp_fl,amp_fr,amp_rl,amp_rr,"
@@ -809,26 +1163,23 @@ int main(int argc, char **argv) {
}
}
if (SDL_Init(SDL_INIT_JOYSTICK) < 0) {
std::cerr << "[오류] SDL 조이스틱 초기화 실패: " << SDL_GetError()
<< std::endl;
return 1;
// 모터(--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 (SDL_NumJoysticks() < 1) {
std::cerr << "[경고] 무선 Xbox 컨트롤러(조이스틱)가 연결되지 않았습니다.\n";
SDL_Quit();
return 1;
}
SDL_Joystick *joystick = SDL_JoystickOpen(0);
if (!joystick) {
std::cerr << "[오류] 조이스틱 열기 실패!\n";
SDL_Quit();
return 1;
}
std::cout << "[정보] C++ Xbox 컨트롤러 연결 성공: "
<< SDL_JoystickName(joystick) << std::endl;
RcReceiver rc(rc_cfg);
rc.start();
std::cout << "[정보] RadioMaster Pocket / XR1 V1.0 RC 수신기 리더 시작 ("
<< rc_cfg.port << "). 신호 수신 전까지는 안전을 위해 브레이크가 "
"걸린 상태로 대기합니다.\n";
SerialPort sp1, sp2;
if (!sp1.openPort(port1))
@@ -887,8 +1238,9 @@ int main(int argc, char **argv) {
}
}
std::cout << "\n[정보] C++ 100Hz 초저지연 루프를 시작합니다. (종료: B 버튼 "
"또는 Ctrl+C, 라이다 토글: X 버튼)\n\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;
@@ -902,13 +1254,14 @@ int main(int argc, char **argv) {
const float loop_hz = 100.0f;
const float dt = 1.0f / loop_hz;
// 저크 제한(jerk-limited) S-curve: 가속도 자체를 매 틱 jerk_limit 만큼만
// 변화시켜 서서히 목표 가속도에 도달하게 함. 기존 구현은 목표 접근 구간만
// 부드러웠고 정지 상태에서 출발할 때는 첫 틱부터 거의 풀가속도가 걸려
// 정지마찰이 급격히 파괴되는 문제가 있었음.
// 가속: 역기전력 보호회로가 하드웨어로 구성되어 더 이상 소프트웨어로
// 완만하게 램프업할 필요가 없어져, 조이스틱 목표값에 1:1로 즉시 추종한다.
// 감속: 기존과 동일하게 저크 제한(jerk-limited) S-curve 유지 — 가속도
// 자체를 매 틱 jerk_limit 만큼만 변화시켜 서서히 목표 가속도에 도달하게
// 해서, 급감속 시 타이어 스크럽/전복 위험을 줄인다.
auto jerkLimitedStep = [dt](float current_vel, float target_vel,
float &current_accel, float max_accel_mag,
float max_decel_mag, float jerk_limit) {
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;
@@ -916,9 +1269,13 @@ int main(int argc, char **argv) {
}
bool is_accelerating = std::abs(target_vel) > std::abs(current_vel);
float accel_cap = is_accelerating ? max_accel_mag : max_decel_mag;
if (is_accelerating) {
current_accel = 0.0f;
return target_vel;
}
// 이번 틱에 diff를 없애는데 필요한 가속도(부호 포함)를 물리적 한계로 클램프
float desired_accel = std::clamp(diff / dt, -accel_cap, accel_cap);
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,
@@ -943,47 +1300,145 @@ int main(int argc, char **argv) {
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는 정지출발용 accel_rate로 천천히 재가속되면, 그 사이 바퀴가
// 지면 이동속도를 못 따라가 끌리며 긁히는 문제가 있어 즉시 target으로
// 스냅시켜 지연을 없앤다.
// 있는데 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; // 드라이버 알람(과전류/과부하 등) 발생 여부
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;
while (g_running) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_JOYBUTTONDOWN) {
if (event.jbutton.button == 0) { // A 버튼: 트립 리셋
trip_initialized = false;
} else if (event.jbutton.button ==
2) { // X 버튼: 라이다 장애물 회피 토글
use_lidar = !use_lidar;
std::cout << "\n[정보] 라이다 회피 기능: "
<< (use_lidar ? "ON" : "OFF") << std::endl;
} else if (event.jbutton.button == 1 ||
event.jbutton.button == 6) { // B or Back
g_running = 0;
}
// ---------------------------------------------------------------------
// 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;
}
}
Sint16 lx_raw = SDL_JoystickGetAxis(joystick, 0);
Sint16 ly_raw = SDL_JoystickGetAxis(joystick, 1);
Sint16 rx_raw = SDL_JoystickGetAxis(joystick, 3);
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;
}
}
float ly = applyDeadzone(-static_cast<float>(ly_raw) / 32767.0f);
float lx = applyDeadzone(static_cast<float>(lx_raw) / 32767.0f);
float rx = applyDeadzone(static_cast<float>(rx_raw) / 32767.0f);
// 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으로 표시
}
if (std::abs(lx) < 0.15f)
lx = 0.0f;
if (std::abs(rx) < 0.15f)
rx = 0.0f;
if (!rc_ok && prev_rc_ok) {
std::cout << "\n[경고] RC 신호 Fail-safe 발동 (" << rc_cfg.failsafe_timeout_ms
<< "ms 이상 미수신) -> 비상 정지.\n";
}
prev_rc_ok = rc_ok;
float v_x = ly * max_v; // 선속도 m/s
float omega = 0.0f; // 각속도 rad/s
// 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
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;
}
}
// ---------------------------------------------------------------------
// LiDAR Obstacle Avoidance & Stopping Logic Integration
@@ -1011,8 +1466,8 @@ int main(int argc, char **argv) {
}
}
// 2. Side Dodge Steering (전진 중만 동작)
if (v_x > 0.01f && rx == 0.0f) {
// 2. Side Dodge Steering (전진 중, 사용자 조향 입력이 없을 때만 동작)
if (v_x > 0.01f && omega == 0.0f) {
float right_intensity = 0.0f;
float left_intensity = 0.0f;
@@ -1054,17 +1509,18 @@ int main(int argc, char **argv) {
}
}
// 제자리 회전 여부: 왼쪽 스틱 전후(ly) 입력이 완전히 중립일 때만 진입한다.
// 그렇지 않으면(주행 중 오른쪽 스틱이 드리프트 등으로 미세하게라도 감지될
// 경우) 전진 입력이 통째로 무시되고 제자리 회전 로직으로 튀어버려, 커브
// 선회 중에 갑자기 이상하게 회전하려는 문제가 생긴다. ly는 applyDeadzone을
// 거쳐 데드존 이하일 때 정확히 0.0f를 반환하므로 등호 비교가 안전하다.
bool is_spin_turn = std::abs(rx) > 0.0f && ly == 0.0f;
// 제자리 회전 여부: 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) + 최소 회전반경용 미세 곡선 혼합
omega = -rx * (max_spin_v / (effective_w / 2.0f));
float v_bias = std::abs(rx) * max_spin_v * spin_arc_bias;
// 제자리 회전 (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);
@@ -1073,33 +1529,13 @@ int main(int argc, char **argv) {
target_rl = v_l * rpm_per_ms;
target_rr = -v_r * rpm_per_ms;
} else {
// 직진 및 차동 선회 Kinematics
if (std::abs(rx) == 0.0f && std::abs(lx) == 0.0f &&
std::abs(omega) > 0.0f) {
// Automated avoidance steering active
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;
} else {
// 선회 강도(omega)는 반드시 "현재 전진 속도(v_x)"에 비례해야 커브
// 반경이 스로틀과 무관하게 일정하게 유지된다. 이전 구현은 상수인
// max_v(최고속도)를 기준으로 omega를 계산해서, 저속 주행 중에는
// v_x가 작은데도 omega*k_skid 항은 고속 기준 그대로 커서 안쪽
// 바퀴(v_l 또는 v_r) 속도 부호가 뒤집혀 후진해버리는 문제가 있었다.
// (예: 저속으로 스틱을 왼쪽으로 조금만 꺾어도 로봇이 제자리 회전
// 하듯 튀거나 뒤로 갔다 앞으로 갔다 하는 증상)
omega += -lx * (std::abs(v_x) / (effective_w / 2.0f));
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;
}
// 직진 및 차동 선회 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으로 낮춰
@@ -1114,12 +1550,33 @@ int main(int argc, char **argv) {
if (airborne_rr)
target_rr = 0.0f;
if (state == "STOPPED") {
// 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) {
driver_front.setBrakes(false);
if (driver_rear_ptr)
driver_rear_ptr->setBrakes(false);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
state = "RUNNING";
}
} else if (state == "RUNNING") {
@@ -1147,14 +1604,10 @@ int main(int argc, char **argv) {
}
float jerk_now = is_spin_turn ? spin_jerk_rate : jerk_rate;
cmd_fl = jerkLimitedStep(cmd_fl, target_fl, accel_fl, accel_rate,
decel_rate, jerk_now);
cmd_fr = jerkLimitedStep(cmd_fr, target_fr, accel_fr, accel_rate,
decel_rate, jerk_now);
cmd_rl = jerkLimitedStep(cmd_rl, target_rl, accel_rl, accel_rate,
decel_rate, jerk_now);
cmd_rr = jerkLimitedStep(cmd_rr, target_rr, accel_rr, accel_rate,
decel_rate, jerk_now);
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) {
@@ -1162,14 +1615,10 @@ int main(int argc, char **argv) {
}
} 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,
decel_rate, jerk_now);
cmd_fr = jerkLimitedStep(cmd_fr, target_fr, accel_fr, decel_rate,
decel_rate, jerk_now);
cmd_rl = jerkLimitedStep(cmd_rl, target_rl, accel_rl, decel_rate,
decel_rate, jerk_now);
cmd_rr = jerkLimitedStep(cmd_rr, target_rr, accel_rr, decel_rate,
decel_rate, jerk_now);
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;
@@ -1180,10 +1629,8 @@ int main(int argc, char **argv) {
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);
// 스로틀 중립 복귀로 인한 자연 정지: 전자식 브레이크는 잠그지 않는다
// (CH5를 눌렀을 때만 잠금 — 위 브레이크 최우선 로직 참고).
state = "STOPPED";
}
}
@@ -1333,8 +1780,13 @@ int main(int argc, char **argv) {
float t_ms = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - log_start_time)
.count();
log_stream << t_ms << ',' << state << ',' << ly << ',' << lx << ','
<< rx << ',' << cmd_fl << ',' << cmd_fr << ',' << cmd_rl
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] << ','
<< cmd_fl << ',' << cmd_fr << ',' << cmd_rl
<< ',' << cmd_rr << ',' << fl_fb << ',' << fr_fb << ','
<< rl_fb << ',' << rr_fb << ',' << fl_amp << ',' << fr_amp
<< ',' << rl_amp << ',' << rr_amp << ',' << wheel_status_str[0]
@@ -1344,9 +1796,20 @@ int main(int argc, char **argv) {
<< dist_axle << ',' << comm_ms << '\n';
}
printf("\r\033[K[%-8s] AXLE:%5.3fm | %-28s | I(A):FL%+4.1f FR%+4.1f "
"RL%+4.1f RR%+4.1f | WHL(FL/FR/RL/RR):%s%s | %4.1fms",
state.c_str(), dist_axle, lidar_telemetry.c_str(), fl_amp, fr_amp,
// 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 | AXLE:%5.3fm | %-28s | "
"I(A):FL%+4.1f FR%+4.1f RL%+4.1f RR%+4.1f | "
"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,
dist_axle, lidar_telemetry.c_str(), fl_amp, fr_amp,
rl_amp, rr_amp, wheel_status_str,
fault_active ? " [!!알람!!]" : "", comm_ms);
fflush(stdout);
@@ -1373,9 +1836,7 @@ int main(int argc, char **argv) {
std::cout << "[정보] CSV 로그 저장 완료: " << log_path << "\n";
}
if (joystick)
SDL_JoystickClose(joystick);
SDL_Quit();
rc.stop();
std::cout << "[정보] C++ 4WD 제어 프로그램이 성공적으로 종료되었습니다.\n";
return 0;
Binary file not shown.