모션 상태 머신
함수 포인터 기반 State 패턴으로 설계한 모션 제어 상태 머신. 두 제품이 공통 상태 전이 로직을 공유하고 제품별 하드웨어 로직만 독립 구현한다.

배경
Rovothome의 두 제품은 하드웨어 구성이 다르지만 모션 제어 흐름은 동일하다:
- Ceily (로봇 침대): 단일 모터, 상/하 리밋 스위치, 수직 이동
- Wally (벽면 리프트): 듀얼 모터(좌/우 휠), ToF 센서, 좌우 각도 동기화 필요
공통 흐름: Open/Close 명령 → 이동 → 리밋 도달 → 정지. 이동 중 충돌이나 장애물을 감지하면 비상 정지(Emergency) 상태로 진입한다.
목표: 상태 전이 로직을 하나의 상태 머신으로 공통화하고, 제품별 센서/모터/안전 로직은 인터페이스를 통해 독립 구현한다.
핵심 문제
- 제품마다 하드웨어가 다름 — Ceily는 단일 모터와 리밋 스위치, Wally는 듀얼 모터와 ToF 센서를 사용한다. 상태 전이 흐름은 같지만 센서 입력과 모터 제어 방식이 완전히 다르다.
- 안전 이벤트가 다양함 — E-stop, 토크 기반 충돌 감지, ToF 장애물 감지 등 비상 정지 진입 조건이 제품마다 다르다.
- 실시간 제약 — FreeRTOS 태스크에서 100~150ms 주기로 상태를 갱신한다(Ceily 100ms, Wally 150ms). 상태 전이는 결정적이고 예측 가능해야 한다.
접근 방식
State 패턴 (함수 포인터 기반)
C에서 State 패턴을 함수 포인터 구조체로 구현했다:
struct MotionState {
void (*enter)(MotionStatePtr*, uint8_t state_from);
void (*exit)(MotionStatePtr*, uint8_t state_to);
void (*tick)(MotionStatePtr*, const void* context);
void (*open_command_handler)(MotionStatePtr*, uint8_t sender);
void (*close_command_handler)(MotionStatePtr*, uint8_t sender);
void (*stop_command_handler)(MotionStatePtr*, uint8_t sender);
uint8_t state;
};
8개 상태(Init, Stop, Opening, Closing, Opened, Closed, Emergency, Manual)가 각각 이 구조체의 인스턴스다. 필요한 핸들러만 구현하고 나머지는 NULL로 두면 된다.
상태 전이는 ChangeState() 함수 하나로 처리한다. exit → 콜백 알림 → enter 순서로 실행되며, 전이 시점의 부수 효과(모터 정지, LED 변경 등)를 각 상태가 독립적으로 관리한다.
상태 전이
| 상태 | 설명 | 전이 조건 |
|---|---|---|
| Init | 부팅 직후. 모터 알람 초기화 | → Stop (즉시) |
| Stop | 중간 위치 정지. 리밋 감지 시 Opened/Closed로 보정 | Open/Close 명령 → Opening/Closing |
| Opening | 열림 방향 이동 중 | 리밋 감지 → Opened, 정지 명령 → Stop |
| Closing | 닫힘 방향 이동 중 | 리밋 감지 → Closed, 정지 명령 → Stop |
| Opened | 완전 열림 위치 | Close 명령 → Closing |
| Closed | 완전 닫힘 위치 | Open 명령 → Opening |
| Emergency | 안전 이벤트 발생 시 진입 | 유지 시간 경과 후 자동 → Stop, 또는 명령으로 해제 |
| Manual | 외부에서 수동으로 모터를 움직일 때 진입 | 수동 조작 종료 → Stop |
제품 독립적 구조 (device_ops)
상태 머신 코어는 제품별 하드웨어를 직접 알지 못한다. device_ops_t 인터페이스를 통해 제품별 구현을 주입받는다:
typedef struct {
void (*init_states)(void (*cb)(uint8_t, uint8_t));
MotionState* init_state;
bool (*check_ready)(void);
void (*read_sensors)(void);
void (*apply_velocity)(void);
bool (*is_moving)(uint8_t state);
void (*clear_alarms)(void);
void (*on_position_changed)(uint8_t pct);
void (*on_manual_tick)(void);
} device_ops_t;
Ceily와 Wally가 각각 이 인터페이스를 구현한다:
- 공통 코드 (
common_components):motion_controller.c,motion_state.c,init_state.c,manual_state.c,kinematics_base.c,speed_config.c— 6개 파일 - 제품별 코드 (각 제품 디렉토리):
stop_state.c,emergency_state.c,opening_state.c등 상태 핸들러 — 제품당 7개 파일
메인 루프 구조:
명령 큐 처리 → 센서 읽기 → tick (상태 갱신) → 속도 적용
motion_controller.c의 태스크가 이 순서를 매 주기 반복한다. 명령(Activate/Deactivate/Stop)은 큐를 통해 전달되어 태스크 컨텍스트에서 처리된다.
비상 정지 처리
비상 정지 진입 시:
- 감속 정지 —
StopMission(500ms)로 제어된 감속 - LED 피드백 — 에러 코드별 빨간 LED 패턴
- 모터 전원 차단 — 감속 완료(500ms) 후 모터 비활성화
- 자동 복귀 — 유지 시간 경과 후 Stop으로 전환 (Ceily 3초, Wally 5초)
- 명령으로 해제 — 대기 중 Open/Close 명령이 오면 즉시 해제
제품별 차이: Ceily는 명령 수신 시 Stop으로 전환한 뒤 다시 이동하고, Wally는 모터가 정지된 상태라면 바로 Opening/Closing으로 직접 전환한다.
트레이드오프
| 결정 | 이유 | 대가 |
|---|---|---|
| C 함수 포인터 vs C++ 가상 함수 | ESP-IDF C 기반 프로젝트, FreeRTOS 호환성 | 타입 안전성 부족, 수동 초기화 필요 |
| 상태별 파일 분리 | 각 상태를 독립적으로 수정 가능 | 파일 수 증가 (상태당 .c/.h 2개) |
| 비상 정지 자동 복귀 | 일시적 이벤트(가벼운 접촉) 후 자동 복구 | 지속적 장애물 시 반복 진입 가능 |
| device_ops 의존성 주입 | 공통 코드와 제품 코드 완전 분리 | 함수 포인터 간접 호출 오버헤드 (미미) |
결과
- 코드 재사용: 상태 머신 코어 6개 파일(상태 전이, 초기화, 속도 제어 등)을 두 제품이 공유. 제품별로는 상태 핸들러 7개 파일만 구현하면 된다
- 제품별 확장: Wally의 좌우 휠 각도 동기화, Ceily의 레그 접이 제어 등 제품 고유 로직을 기존 코드 수정 없이 상태 핸들러 안에서 독립 구현
- 안전성: 비상 정지를 통한 일관된 안전 응답 — 500ms 내 감속 정지, 모터 전원 차단, 에러 코드 로깅
- 현장 안정성: 양산 배포 이후 상태 머신 관련 장애 없이 운영 중