Motion State Machine
Product-agnostic motion control state machine using the State pattern — shared across Ceily and Wally products

Context
Two Rovothome products have different hardware but share the same motion control flow:
- Ceily (robotic bed): Single motor, limit switches, vertical travel
- Wally (wall-mounted lift): Dual motors (left/right wheels), limit switches + dual-motor angle synchronization
Both follow the same pattern: Activate/Deactivate command → moving → limit reached → stopped, with Emergency entry on collision detection.
Goal: Share state transition logic across both products while keeping product-specific motor control and safety logic independent.
Core Problem
- Different hardware per product — Both use limit switches for end-position detection, but Wally additionally requires dual-motor angle synchronization. State transitions are identical; sensor inputs and motor control differ.
- Torque-based collision detection — Each moving state runs a torque model (with acceleration, constant-velocity, and deceleration phases) to detect obstructions. The model parameters and thresholds differ between products.
- Real-time constraints — The state machine ticks periodically (100ms for Ceily, 150ms for Wally) in a FreeRTOS task. Transitions must be deterministic.
Approach
State Pattern (function pointers in C)
Each state is a struct of function pointers — the C equivalent of the State pattern:
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;
};
Eight states (Init, Stop, Activating, Deactivating, Activated, Deactivated, Emergency, Manual) each provide their own handler implementations. The main loop processes commands from a queue, then calls tick() on the current state at each interval. State transitions happen by swapping the current_state pointer, with exit() and enter() called automatically.
State Transitions
The internal enum uses Activating/Deactivating/Activated/Deactivated, mapped to product terms (Ceily: MoveUp/MoveDown/Up/Down; Wally: Opening/Closing/Opened/Closed).
| State | Description | Transition |
|---|---|---|
| Init | Post-boot. Clears motor alarms | → Stop (on first tick) |
| Stop | Intermediate position | Activate/Deactivate → Activating/Deactivating |
| Activating | Moving toward activated position | Limit detected → Activated; any command → Stop |
| Deactivating | Moving toward deactivated position | Limit detected → Deactivated; any command → Stop |
| Activated | Fully activated position | Deactivate → Deactivating |
| Deactivated | Fully deactivated position | Activate → Activating |
| Emergency | Safety event triggered | Auto → Stop after hold period (3–5s), or command to clear |
| Manual | User physically moving the device | Auto → Stop when manual input ends |
Product-Agnostic Architecture (device_ops)
The common motion controller receives product-specific implementations through a device_ops_t interface:
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 and Wally each implement this interface. The common component (common_components/motion_controller/) contains the state machine core (motion_controller.c, motion_state.c), shared states (init_state.c, manual_state.c), and shared utilities (kinematics_base.c, speed_config.c). Product-specific states (stop_state.c, emergency_state.c, opening_state.c, etc.) live in each product’s states/ directory.
Emergency Handling
On Emergency entry:
- Controlled deceleration —
StopMission(500ms)ramp-down - LED feedback — LED pattern mapped to error code
- Motor disable — Power cut after deceleration completes
- Auto-recovery — Transitions to Stop after a hold period (Ceily: 3s, Wally: 5s)
- Command override — Activate/Deactivate command during Emergency clears the state (Ceily transitions to Stop; Wally transitions directly to the commanded direction if motors have stopped)
Tradeoffs
| Decision | Rationale | Cost |
|---|---|---|
| C function pointers vs C++ virtual | ESP-IDF is C-based, FreeRTOS compatibility | Less type safety, manual initialization |
| Separate file per state | Each state independently modifiable | More files (2 per state: .c/.h) |
| Emergency auto-recovery (3–5s) | Handles transient events (light contact) gracefully | Persistent obstructions cause repeated entry |
| device_ops dependency injection | Clean separation of common and product code | Indirect call overhead (negligible) |
Results
- Code reuse: 6 files in
common_components/motion_controller/shared across both products without modification - Product extensibility: Adding Wally required implementing its state handlers and
device_ops— zero changes to the common core - Collision detection: Three-phase torque model (acceleration / constant-velocity / deceleration) detects obstructions in real time and triggers Emergency
- Safety: Controlled stop within 500ms, automatic motor disable, product-configurable recovery timeout
- Reliability: Zero state machine-related field bugs across deployed units