Email me
Self-balancing robot
← Projects

Robotics

Self-balancing robot

A two-wheeled robot built to learn robotics

Active Started 2 Mar 2026 Updated 27 Aug 2026
Built with
  • stm32
  • c++
  • embedded
  • robotics
Two-wheeled self-balancing robot prototype

This project started with a Yahboom self-balancing car kit. After playing around with it and working through some of the supplied tutorials, I decided to turn it into a larger project that I could learn more from.

I have never found strictly following tutorials to be the best way for me to learn. Instead, I decided to replace most of the original C application and board-support code with C++. I normally work with Python and TypeScript, so this gave me a reason to get more experience with modern C++ on an actual embedded system. Rebuilding the firmware has also helped me understand the complete path from an IMU packet to a motor command, while keeping the original project as a reference whenever something goes wrong.

My longer-term plan is to add a Radxa Q6A for vision, path planning and voice interaction. I had originally planned to use a Raspberry Pi 5, but the prices became difficult to justify, so I decided to give the Q6A a go instead. It will send movement setpoints over UART while the STM32 handles the real-time balance loop.

This project also has a Build it yourself section for anyone who would like to try something similar. The robot is not finished and does not balance unassisted yet, but the C++ firmware now reads fresh IMU packets and drives both motors in the correct direction during restrained tests. I will continue updating the walkthrough whenever I complete a major feature or milestone.

Where it is at right now

The car does not balance unassisted yet, but here is what is working, what is currently in development, and what I have planned.

Working now

  • The complete restrained signal path: a fresh MPU6050 DMP packet reaches the balance controller, which commands both motors in the physically correct direction
  • One control implementation running against both the STM32 hardware and a simulation physics model
  • A GCC firmware build that flashes over ST-Link/SWD, with a second compiler building it in CI
  • Fresh-sample gating, so a rejected IMU poll cannot advance the controller using cached data
  • A TIM2 monotonic clock measured at 1.001 against an independent host clock
  • Angle-fault hysteresis, observed tripping at about 40 degrees and clearing near 10 degrees
  • Eighteen host tests in source. The last recorded complete run passed 18/18 on 22 August; the local host build currently needs reconfiguration before that result is reproducible again

In development

  • A stale-data shutdown that writes zero PWM after 25 ms without a fresh IMU sample
  • Nine host tests for its deadline, recovery behaviour and 32-bit timer wrap
  • A restrained fault-injection test. The measured worst-case IMU poll is 4 ms, but the final zero-PWM evidence still has to be captured

Planned

  • Tuning the upright gains and recording the first sustained free balance
  • Validating encoder signs before enabling velocity hold
  • Moving the control schedule onto the IMU data-ready interrupt and deciding how the FIFO should be drained without exceeding the loop budget
  • Enabling turn control once upright and velocity behaviour are understood separately
  • Adding the Radxa Q6A as a second compute domain for vision, navigation and voice interaction

Updates

Recent progress

All 6 updates →

Updates are bullet-point summaries generated with AI from my commits and build notes, then checked by me. Project articles and learning notes are written by me.

  1. AI summary

    • Verified TIM2 counts real milliseconds: 6,212 ms on the MCU against 6,208 ms on a PC stopwatch over 63 telemetry lines (ratio 1.001). Cross-checked a second way against the sensor cadence.
    • The battery ADC read between 4.96 V and 13.29 V under motor load on an 8.4 V pack, so the measurement is wrong rather than the pack. Leading suspect is ADC sampling that isn’t synchronised with the 25 kHz motor PWM. Not yet confirmed.
    • The low-battery cutoff stays disabled until the reading is understood. Averaging more samples first needs a wider accumulator than uint16_t.
  2. AI summary

    • Removed the delay_ms(5) after each loop. It added 5 ms on top of about 3.34 ms of work, so the loop ran near 120 Hz against a 200 Hz sensor. Motor jitter dropped straight away.
    • The controller, encoders and telemetry now advance only on a complete fresh DMP packet, because the loop polls 625 to 770 times a second without the delay.
    • Added an IMonotonicClock interface for the stale-IMU motor timeout. Measured cost: 616 bytes of flash and 8 bytes of BSS, under 1% of the 256 KB flash.
    • See Run the controller only on fresh samples and Separate the control logic from the hardware.
  3. AI summary

    • Confirmed the MPU6050 interrupt is on PA12, not PB12. EXTI line 12 is shared across ports and the port is chosen in GPIO_EXTILineConfig. PB12 is the PS2 controller’s chip select. Cross-checked by extracting the netlist from the board schematic PDF.
    • Withdrew a finding that the 1300 motor deadzone needed rescaling. The vendored reference uses the same 2880 timer period, so the deadzone and the PID gains carry over directly.
    • Capped the FIFO drain loop at 3 reads (about 2.5 ms) instead of 32 (about 27 ms on the bit-banged bus). Past the cap it zeroes PWM, resets the FIFO and counts the event.
    • Found that a failed IMU read leaves the last PWM command latched on the motors, with nothing expiring it. A stale-data motor cutoff has to land before the gains go up.
    • Added hysteresis to the tilt fault: it trips at 40° and clears at 10°, so the loop only restarts near upright.