Firmware for an STM32F401CCU6-based differential-drive (2-wheel) robot controller. It accepts speed commands from either a TBS CRSF receiver or a Raspberry Pi, and drives an L298N motor driver through an encoder-based (optionally BNO080 IMU-assisted) speed control loop.
- CRSF always takes priority when the link is alive. The Raspberry Pi command is only used when CRSF is disconnected.
- If neither source is present, the motors stop (failsafe).
| Pin | Function | Mode |
|---|---|---|
| PA0 | L298N ENA (Motor A PWM) | TIM5_CH1 |
| PA1 | L298N ENB (Motor B PWM) | TIM5_CH2 |
| PA2 | Raspberry Pi link TX | USART2_TX |
| PA3 | Raspberry Pi link RX | USART2_RX |
| PA4 | L298N IN1 | GPIO_Output |
| PA5 | L298N IN2 | GPIO_Output |
| PA6 | L298N IN3 | GPIO_Output |
| PA7 | L298N IN4 | GPIO_Output |
| PA9 | CRSF TX (telemetry) | USART1_TX |
| PA10 | CRSF RX | USART1_RX |
| PA13 | SWDIO | SYS |
| PA14 | SWCLK | SYS |
| PA15 | Encoder1 channel A | TIM2_CH1 |
| PB3 | Encoder1 channel B | TIM2_CH2 |
| PB4 | Encoder2 channel A | TIM3_CH1 |
| PB5 | Encoder2 channel B | TIM3_CH2 |
| PB6 | BNO080 SCL | I2C1_SCL |
| PB7 | BNO080 SDA | I2C1_SDA |
Clocks: HSE 25 MHz → SYSCLK 84 MHz, APB1 42 MHz (timers 84 MHz), APB2 84 MHz
Driver assignment
| Peripheral | Driver |
|---|---|
| RCC, GPIO, TIM2, TIM3, TIM5 | LL |
| I2C1 (BNO080) | HAL |
| USART1 (CRSF) | LL + DMA2 Stream2 circular RX + IDLE-line interrupt |
| USART2 (Raspberry Pi) | HAL + byte-wise interrupt |
The encoders sit on the gearbox output shaft, so their counts are treated directly as wheel (output-shaft) rotation — no extra gear ratio is applied.
Core/ CubeMX-generated code (main.c, etc.). Only the USER CODE sections call into App/
App/Inc, Src/
app.c app_init()/app_run() top-level orchestration, 200 Hz control loop
app_config.h Fixed constants + robot_config default-value macros
robot_config.c Runtime parameter table (PARAM_*), persisted to flash sector 5
crsf.c USART1 LL+DMA+IDLE CRSF parser, RC channel decode, ATTITUDE telemetry TX
rpi_link.c USART2 framing transport layer (pure send/receive, no protocol semantics)
host_commands.c Interprets/answers Raspberry Pi & configurator commands (built on rpi_link)
command_source.c Picks CRSF vs. Raspberry Pi as the active command source (CRSF wins)
encoder.c TIM2/TIM3 LL quadrature encoder wrapper, sign inversion, raw tick accumulator
motor.c TIM5 LL PWM + L298N direction GPIO wrapper, sign inversion
pid.c Generic PID controller
drive_controller.c Left/right wheel speed PID + yaw control (encoder differential or IMU gyro trim)
bno080.c BNO080 SHTP/SH2 I2C driver (Rotation Vector + Gyro, DCD calibration save)
crc8.c CRC8-DVB-S2 (poly 0xD5) used by CRSF
cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/gcc-arm-none-eabi.cmake -DCMAKE_BUILD_TYPE=Debug
cmake --build buildarm-none-eabi-gcc must be on PATH. App/Src/*.c is picked up automatically via glob in the top-level CMakeLists.txt.
Flash layout: the last sector (Sector 5, 0x08020000, 128 KB) is reserved for configuration storage, so firmware code must stay under 128 KB (currently ~26 KB).
app_run() runs the following every 5 ms (CONTROL_LOOP_PERIOD_MS, 200 Hz):
bno080_poll()— non-blocking IMU report pollcommand_source_get()— checks CRSF link (300 ms timeout) first; if connected, uses CRSF channels, otherwise falls back to the last Raspberry Pi velocity command (500 ms timeout), or zero if neither is presentdrive_controller_update()— converts the target linear/angular velocity into left/right wheel speed targets, runs encoder-feedback PID to produce PWM duty, and optionally adds an IMU yaw-rate trim- Sends telemetry to the Raspberry Pi every 20 ms (
TELEMETRY_PERIOD_MS) and attitude telemetry over CRSF every 100 ms (CRSF_TELEMETRY_PERIOD_MS)
USART2, 115200 8N1. Framing matches uart.py / stm32_uart.py from Delibot-RPi-Controller.
Frame format
[0xFF][LEN][CMD][DATA...][CRC]
LEN= number of bytes inCMD+DATA(i.e.len(DATA) + 1)CRC= 1-byte XOR checksum overCMDand everyDATAbyte (crc = CMD; for b in DATA: crc ^= b)- Minimum frame length is 4 bytes (commands with no
DATAhaveLEN = 1)
On a CRC mismatch the frame is discarded and the parser resyncs by searching for the next 0xFF.
Host (Pi) → STM32
| CMD | Name | Payload | Description |
|---|---|---|---|
0x01 |
VELOCITY |
int16 linear_mm_s, int16 angular_mrad_s (LE) |
Target velocity. Also doubles as a heartbeat — if not received within 500 ms, the link is considered lost |
0x02 |
PARAM_GET |
uint8 param_id |
Request a parameter value → replies with PARAM_VALUE |
0x03 |
PARAM_SET |
uint8 param_id, float32 value (LE) |
Applies the value to RAM immediately (not persisted to flash) → replies with PARAM_VALUE |
0x04 |
PARAM_SAVE |
none | Commits the entire in-RAM parameter set to flash |
0x05 |
PARAM_RESET_DEFAULTS |
none | Resets all parameters to defaults (RAM only — send PARAM_SAVE separately to persist) |
0x06 |
PARAM_LIST_REQUEST |
none | Streams a PARAM_VALUE reply for every parameter, in order |
0x07 |
CRSF_CHANNELS_REQUEST |
none | Request raw RC channel values → replies with CRSF_CHANNELS |
0x08 |
ENCODER_RAW_REQUEST |
none | Request accumulated encoder ticks → replies with ENCODER_RAW |
0x09 |
ENCODER_ZERO |
none | Resets the accumulated encoder tick counters to 0 |
0x0A |
IMU_SAVE_CALIBRATION |
none | Sends the SH2 "Save DCD" command to the BNO080 (persists its own calibration) |
0x10 |
DEBUG_PRINT |
none (ignored on receive) | Reserved; STM32 → Pi direction only |
0xFF |
HEALTH_CHECK |
uint8 = 0x01 |
Ping. Echoed back with the same payload immediately |
STM32 → Host (Pi)
| CMD | Name | Payload | Description |
|---|---|---|---|
0x81 |
TELEMETRY |
int16 yaw_centideg, int16 yaw_rate_centideg_s, int16 left_wheel_mm_s, int16 right_wheel_mm_s, uint8 status_flags (all LE) |
Periodic status broadcast, every 20 ms |
0x82 |
PARAM_VALUE |
uint8 param_id, float32 value (LE) |
Reply to PARAM_GET / PARAM_SET / PARAM_LIST_REQUEST |
0x83 |
CRSF_CHANNELS |
uint16 channel[16] (LE) |
Raw values of all 16 RC channels (CRSF standard range: 172–1811) |
0x84 |
ENCODER_RAW |
int32 left_ticks, int32 right_ticks (LE) |
Accumulated ticks, before the ENCODER_*_SIGN inversion is applied |
0x10 |
DEBUG_PRINT |
ASCII text | Debug log line (e.g. sent on BNO080 init failure) |
0xFF |
HEALTH_CHECK |
uint8 = 0x01 |
Ping reply |
status_flags bits: bit0 CRSF connected, bit1 Raspberry Pi link active, bit2 IMU OK, bit3 motors driving.
Note: the
stm32_uart.pyreference we received doesn't yet define command constants or helper methods forVELOCITY/PARAM_*/TELEMETRYetc. The Raspberry Pi'sSTM32_UARTclass needs matching command IDs added from the table above before the two sides can actually talk. Also, itsUART('/dev/ttyACM0', ...)port is hardcoded — this board uses USART2 (PA2/PA3) GPIO UART rather than USB, so on the Raspberry Pi it will typically enumerate as/dev/ttyAMA0or/dev/serial0instead. That needs to be fixed on the Raspberry Pi side.
PARAM_SET applies a value immediately; you must also send PARAM_SAVE for it to survive a reboot (without it, the value only lives in RAM and the next boot reloads whatever was last saved to flash, or the defaults).
| ID | Name | Default | Description |
|---|---|---|---|
| 0 | CRSF_STEERING_CHANNEL |
0 | CRSF channel index (0-based) used for steering |
| 1 | CRSF_THROTTLE_CHANNEL |
1 | CRSF channel index used for forward/back throttle |
| 2 | CRSF_STEERING_SIGN |
1.0 | Steering channel sign (±1) |
| 3 | CRSF_THROTTLE_SIGN |
1.0 | Throttle channel sign (±1) |
| 4 | CRSF_DEADBAND |
10 | Channel center deadband, in raw units |
| 5 | MOTOR_LEFT_SIGN |
1.0 | Left motor rotation direction sign |
| 6 | MOTOR_RIGHT_SIGN |
1.0 | Right motor rotation direction sign |
| 7 | ENCODER_LEFT_SIGN |
1.0 | Left encoder count direction sign |
| 8 | ENCODER_RIGHT_SIGN |
1.0 | Right encoder count direction sign |
| 9 | WHEEL_TRACK_WIDTH_MM |
200.0 | Distance between the wheel centers (mm) |
| 10 | WHEEL_DIAMETER_MM |
65.0 | Wheel diameter (mm) |
| 11 | ENCODER_COUNTS_PER_REV |
2800.0 | Encoder counts per wheel (output-shaft) revolution, X4 mode |
| 12 | MAX_LINEAR_SPEED_MM_S |
1500.0 | Target linear speed at full throttle stick (mm/s) |
| 13 | MAX_ANGULAR_SPEED_MRAD_S |
3000.0 | Target angular speed at full steering stick (mrad/s) |
| 14 | WHEEL_PID_KP |
0.0015 | Wheel speed PID proportional gain |
| 15 | WHEEL_PID_KI |
0.02 | Wheel speed PID integral gain |
| 16 | WHEEL_PID_KD |
0.0 | Wheel speed PID derivative gain |
| 17 | YAW_CONTROL_SOURCE |
0 | 0 = encoder differential only, 1 = add IMU gyro yaw-rate trim |
| 18 | YAW_RATE_PID_KP |
0.3 | (IMU mode) Yaw-rate PID proportional gain |
| 19 | YAW_RATE_PID_KI |
0.1 | (IMU mode) Yaw-rate PID integral gain |
| 20 | YAW_RATE_PID_KD |
0.0 | (IMU mode) Yaw-rate PID derivative gain |
All values are transferred uniformly as float32 (even parameters that are semantically integers, like CRSF_STEERING_CHANNEL, are sent/received as float and cast to int in firmware).
- Confirm the link with
HEALTH_CHECK(0xFF) - Poll
CRSF_CHANNELS_REQUEST(0x07) to monitor channels — wiggle the sticks to identify the steering/throttle channel and direction, then setCRSF_STEERING_CHANNEL/CRSF_THROTTLE_CHANNEL/*_SIGN - Send
ENCODER_ZERO(0x09), manually rotate a wheel exactly N turns, then read raw ticks withENCODER_RAW_REQUEST(0x08) and computeENCODER_COUNTS_PER_REV = raw_ticks / N; check wheel direction fromTELEMETRY(left/right wheel mm/s) and flipMOTOR_*_SIGN/ENCODER_*_SIGNif needed - Enter the measured
WHEEL_TRACK_WIDTH_MMandWHEEL_DIAMETER_MM - To use IMU-assisted yaw control, wave the robot through a figure-8 by hand, send
IMU_SAVE_CALIBRATION(0x0A), then setYAW_CONTROL_SOURCE = 1 - Finish with
PARAM_SAVE(0x04) — without it, everything resets on the next reboot
- The physical constants (track width, wheel diameter, encoder CPR) and PID gains in
robot_configare all placeholders — they need to be calibrated against the real robot - Verify that
MOTOR_*_SIGNandENCODER_*_SIGNmatch the actual wiring after first boot - The "Delibot Configurator" PC app itself is out of scope for this repository — implementing the protocol documented here is sufficient
- The BNO080 INT/RST pins are not wired, so it only runs in polling mode (no low-latency interrupt-driven updates)