CargoBot is a project I built combining electronics with software written in Rust. It is basically a car with torquey motors that can carry different objects and based on the load detected via LM393 encoders and IMU MPU-6500 it adjusts the speed of the motors, increasing pwm %.
Both the hardware and software diagrams are drawn in draw.io.
The robot is built on a 4WD chassis powered by four DC motors (3–6V) wired in parallel per side (skid steering) and driven by an L298N dual H-bridge. Speed is dynamically regulated via 1kHz PWM signals from the STM32, while directional control is managed through discrete GPIO pins. Two LM393 IR optical sensors read wheel encoder discs to provide real-time RPM feedback, which serves as the primary metric for the PI load-compensation algorithm.
For environmental and physics telemetry, an MPU-6500 IMU communicates over a shared I2C bus to track linear accelerations (
The system is organized around five main subsystems:
1. MCU (STM32 Nucleo-U545RE-Q) The central unit running all Embassy-rs async tasks. Coordinates all subsystems and shared state protected by Mutex.
2. Motor Subsystem The L298N dual H-bridge receives PWM signals from the STM32 and drives 4 DC motors (2 per channel, left/right side in parallel - skid steering). The two IR LM393 optical sensors read encoder disc pulses on GPIO interrupts and compute RPM per side.
3. Sensing Subsystem
- MPU-6500 (I2C, address 0x68): reads accelerometer + gyroscope data, combined via complementary filter to get a stable tilt angle
- 2x HC-SR04 (GPIO trigger/echo): front and rear obstacle detection
- 2x IR LM393 encoders: RPM feedback for PID
4. Communication Subsystem HC-06 Bluetooth module connected to STM32 via UART. Bidirectional: laptop sends raw keyboard characters (w, a, s, d, x, p), and the robot streams back high-efficiency text-based CSV telemetry (T,Load,Comp,Ax,Ay,Gz,Slope) at 10Hz to reduce microcontroller overhead.
5. Display & Indicators Subsystem
- OLED SSD1306 128x64 (I2C, address 0x3C, shared bus with IMU): displays active metrics like distance, encoder ticks, load %, slope, and current power mode.
- 3x LEDs (green/blue/red) on GPIO: visual indicator of motor effort based on PWM duty cycle
- PC Dashboard: Tkinter window with embedded real-time Matplotlib animation canvas displaying streaming physics graphs.
The entire program is #![no_std] and #![no_main];
there is no heap, no operating system, and no RTOS scheduler, thus
all concurrency is cooperative and driven by async/await.
The LM393 encoders count the wholes in the DC-motors' encoders when the car is not loaded, make an average between the wheels and have that as a reference. When the car is loaded up or driving on a surface with a high friction coefficient, it counts the wholes in the encoders and if it is less, a percentage is calculated and clamped (so the PWM percentage doesn't exceed 100%).
Similarly, the IMU is calibrated at the beginning in the first second, saving the reference values (to subtract them later), and when it drives up a slope for example, it automatically detects the angle of the slope and tries to compensate for that. Of course, this is clamped as well.
The car has 1 HC-SR04 sensor in the front and one in the back. They are polled at 8Hz with a 30ms gap between each other, so they don't hear noise from one another. When the car approaches an object at less than 15cm, it automatically stops and displays a message on the display.
Data is logged on the display at 2Hz (twice per second) because display updates are quite hefty and take a lot of time. If I updated the display more than twice per second, the motors would have been laggy, because the display acquires the i2c bus mutex, and the IMU waits on that mutex. With 2Hz, the car runs smooth and is responsive to commands.
The car has 3 power modes: ECO, DRIVE and SPORT and they can be chosen from the python tkinter interface.
The command centre of the CargoBot. It receives commands from the keyboard and sends them via Bluetooth to the car. It also displays live data using a 100 points dequeue (the last 100 points events): PID data, slope detection and telemetry.
Embassy runs a single-threaded cooperative executor. Every task is a Rust
async fn marked with #[embassy_executor::task]. Because only one task
runs at a time and tasks yield at every .await point, shared state can be
safely exchanged through lock-free atomics (AtomicU32, AtomicI32,
AtomicBool) for single-value reads/writes, and through an
embassy_sync::channel::Channel for the command queue. The I2C bus, which
is shared between the IMU and the OLED, is protected by an
embassy_sync::mutex::Mutex<ThreadModeRawMutex, I2c> stored in a
StaticCell so that its 'static lifetime can be passed to multiple tasks.
I chose lock-free atomics because they are fast they can be used with ease in more then 2 functions (if I used a channel instead of atomics it would have gotten very complicated very fast). Atomics ar fast and simple.
All inter-task communication goes through a flat set of static atomics.
The table below maps each atomic to the task that writes it and the tasks that reads it.
| Atomic | Type | Writer | Readers | Meaning |
|---|---|---|---|---|
DIST_FRONT_CM |
AtomicU32 |
distance_task |
display_task, main (motor) |
Front HC-SR04 distance (cm) |
DIST_REAR_CM |
AtomicU32 |
distance_task |
display_task, main (motor) |
Rear HC-SR04 distance (cm) |
EMERGENCY_STOP_FRONT |
AtomicBool |
distance_task |
main (motor) |
True when front obstacle < 15 cm |
EMERGENCY_STOP_REAR |
AtomicBool |
distance_task |
main (motor) |
True when rear obstacle < 15 cm |
ENC_L_TICKS |
AtomicU32 |
encoder_left_task |
pid_task, display_task |
Cumulative left encoder ticks (20 per slot) |
ENC_R_TICKS |
AtomicU32 |
encoder_right_task |
pid_task, display_task |
Cumulative right encoder ticks |
IMU_AX |
AtomicI32 |
imu_task |
pid_task, telemetry_task |
Accel X x100 (% of 1 g) |
IMU_AY |
AtomicI32 |
imu_task |
pid_task, telemetry_task |
Accel Y x100, LPF-filtered |
IMU_AZ |
AtomicI32 |
imu_task |
telemetry_task |
Accel Z x100 |
IMU_GZ |
AtomicI32 |
imu_task |
pid_task, telemetry_task |
Yaw rate x100 (deg/s), LPF-filtered |
LOAD_PERCENT |
AtomicU32 |
pid_task |
display_task, telemetry_task |
Load % (0 = no load, 100 = motor blocked) |
PID_COMPENSATION |
AtomicI32 |
pid_task |
display_task, telemetry_task |
Symmetric PI output (% PWM delta) |
PID_COMP_LEFT |
AtomicI32 |
pid_task |
main (motor) |
Left wheel comp after yaw mixer |
PID_COMP_RIGHT |
AtomicI32 |
pid_task |
main (motor) |
Right wheel comp after yaw mixer |
SLOPE_COMP |
AtomicI32 |
pid_task |
main (motor), display_task, telemetry_task |
Feed-forward slope compensation (% PWM) |
PWM_LEFT_ACTUAL |
AtomicU8 |
main (motor) |
pid_task |
Actual left PWM duty sent to L298N |
PWM_RIGHT_ACTUAL |
AtomicU8 |
main (motor) |
pid_task |
Actual right PWM duty sent to L298N |
POWER_MODE |
AtomicU8 |
main (motor) |
display_task, telemetry_task |
0=ECO, 1=DRIVE, 2=SPORT |
PID_CALIBRATED |
AtomicBool |
pid_task |
display_task, telemetry_task, pid_task |
True once reference RPM is locked |
REFERENCE_RPM_X100 |
AtomicU32 |
pid_task |
pid_task |
Calibrated cruise RPM x100 |
IS_TURNING |
AtomicBool |
main (motor) |
pid_task |
True during a/d commands |
The single channel:
| Channel | Type | Sender | Receiver | Meaning |
|---|---|---|---|---|
CMD_CHANNEL |
Channel<_, u8, 4> |
bluetooth_task |
main (motor) |
Raw command bytes: w s a d x p |
Embassy-rs Async Tasks:
| Task | Frequency | Responsibility |
|---|---|---|
| encoder_left_task / right | Interrupt-driven | Counts wheel encoder slot edges using GPIO EXTI triggers. |
| distance_task | 8 Hz | Triggers and measures echo responses from front/rear HC-SR04 sensors. |
| imu_task | 50 Hz | Reads raw Accel/Gyro data from MPU-6500 over I2C and stores scaled values. |
| pid_task | 5 Hz | Performs Low-Pass filtering, fast auto-calibration, and runs the PI speed compensation loop. |
| telemetry_task | 10 Hz | Formats data into a CSV string and transmits it wirelessly over UART. |
| display_task | 2 Hz | Refreshes the on-board OLED graphics. |
| bluetooth_task | Async Rx | Listens for incoming control characters from the PC. |
Navigation & PI Compensation Logic: Instead of basic tilt-triggering, CargoBot uses an intelligent sensor-fusion approach:
- Auto-Calibration: On first throttle, it calibrates the ideal steady-state wheel RPM.
- Load Tracking: A Low-Pass filter smooths out encoder noise. If the filtered RPM drops below the reference value due to cargo weight or friction, the robot calculates the exact
Load %. - PI Regulation: A Proporțional-Integral loop dynamically scales up the PWM duty cycle to maintain constant cruise speed, bypassing inertia during start-up via a dedicated blind window.
Peripheral Usage:
| Peripheral | Component | Usage |
|---|---|---|
| PWM | STM32 -> L298N | Motor speed control (0–100% duty cycle) |
| GPIO Output | STM32 -> HC-SR04 trigger | pulse to trigger ultrasonic |
| GPIO Input Interrupt | HC-SR04 echo -> STM32 | Measure echo duration -> distance |
| GPIO Input Interrupt | LM393 encoders -> STM32 | Count pulses -> compute RPM |
| GPIO Output | STM32 -> LEDs R/G/B | Load indicator: green (ECO mode), blue (DRIVE mode), red (SPEED mode) |
| GPIO Output | STM32 -> L298N IN1-IN4 | Motor direction control |
| I2C (shared bus) | STM32 -> MPU-6500 (0x68) | Accelerometer + gyroscope for tilt angle |
| I2C (shared bus) | STM32 -> SSD1306 (0x3C) | OLED telemetry display |
| UART | STM32 -> HC-06 | Bidirectional Bluetooth: commands in, telemetry out |


