# Team Powerpuffs - WRO® 2025 Future Engineers Engineering materials of a self-driven vehicle's model for the WRO Future Engineers competition, 2026 season, starting with the Turkey stage. --- # 1. Repository Content | Folder | What's inside | | ------------ | -------------------------------------------------------------- | | `t-photos/` | Team photos. | | `v-photos/` | Vehicle photos from every side, top and bottom. | | `video/` | A short driving clip (or link), one per challenge. | | `schemes/` | Connection schemes and power distribution. | | `models/` | CAD models, manufacturing notes, assembly files. | | `src/pi/` | Raspberry Pi 5 code (Python). | | `src/stm32/` | STM32F4 firmware (C, STM32CubeIDE). | | `tests/` | Pytest suite, runs on a laptop with no hardware. | | `other/` | Hardware specs, protocol write-up, longer system description. | --- # 2. Introduction ## 2.1 The Team We are team PowerPuffs. Photos of the three of us plus the coach live in `t-photos/`. ## 2.2 The Challenges Both events run on a square track with movable walls. - **Open Challenge** — drive several laps around the track. The walls are moved into a different layout right before the run, so we cannot memorize one path and replay it. - **Obstacle Challenge** — same track, with red and green traffic signs scattered around it. The colour tells the car which side to pass on. After the laps the car also has to parallel park itself into a marked bay, which honestly caused us more grief than anything else in the project. ## 2.3 High-Level Approach We split the work across two boards: - A **Raspberry Pi 5 (16 GB)** does the thinking — camera, vision, wall-following, obstacle handling, lap counting, state. - An **STM32F4 Nucleo (F410RB)** does the parts that cannot afford to be slow — sensors on a fixed schedule, steering servo, motor, and a watchdog that cuts the motors if the Pi ever goes silent. We did not trust Python timing at the cycle level — one GC pause is enough to ruin a run, so all the timing-critical jobs ended up on the microcontroller. The two boards are joined by a single UART line at 115200 baud, with binary packets and a CRC8 on every one. Steering is an Ackermann linkage on a hobby servo, drive is one DC motor through an open-loop driver, no encoders. The car works out its progress from the camera, the LiDARs and the gyro instead. --- # 3. Vehicle & Team Media - **`t-photos/`** — team photos. - **`v-photos/`** — vehicle from front, back, left, right, top and bottom, under flat lighting so sensor placement and wiring are visible. - **`video/`** — at least 30 seconds per challenge, a complete run, not a cherry-picked corner. Also pinned in this repo's About box. --- # 4. Mobility & Mechanical Design ## 4.1 Chassis layout and material choices We wanted the robot to be small but still big enough to hold all the parts. To achieve this, we made a 2-level robot. We put the battery and DC motors in special slots under the first level. The chassis and almost all of our custom parts are 3D printed using PLA. To put the robot together, we used tools like a soldering iron, different sizes of screws, nuts, washers, screwdrivers, and pliers. We also used tweezers for tiny parts and double-sided tape to attach some components securely. ## 4.2 Steering and drive mechanism (Ackermann geometry) We used a differential to share the motor's power smoothly between the wheels. When the robot turns, the wheels cover different distances, so we needed a system that lets them spin at different speeds. We used equal-sized gears inside the differential to prevent torque differences between the wheels. For steering, we used only one servo motor to make the system more efficient. Our steering system has three main parts: parts attached to the wheels, side parts connected to them, and a middle part connected to the servo. Using these parts (which form the kingpins) and the straight distance between the rear differential and the kingpins, we calculated the Ackermann steering geometry. This setup helps the front wheels turn easily at different angles. ## 4.3 Motor and gearbox selection For our drive system, we use a 6V and 150 RPM DC motor. These specifications are very common for standard N20 micro gear motors. We attached a 24mm diameter gear to the end of this motor to drive the robot. ## 4.4 Torque and speed reasoning To find the perfect balance between speed and control, we connected the 24mm gear on our 6V 150 RPM motor to a 40.75mm gear on the differential. This size difference creates a gear ratio that changes how the robot moves. We can calculate this ratio with a simple formula: **Gear Ratio:** $$ \frac{40.75}{24} \approx 1.7 $$ Because of this 1.7 gear ratio, the speed of the motor is reduced, meaning the differential spins at about 88 RPM instead of 150 RPM. However, what we lose in speed, we gain in torque (pushing power). The torque transferred to the wheels is multiplied by 1.7, giving the robot enough power to carry its weight and accelerate smoothly without stalling. We use wheels that are 60mm in size. With the wheels spinning at 88 RPM, the robot reaches a top speed of roughly 27 centimeters per second. We intentionally chose this mechanical setup to reach the maximum safe speed. 27 cm/s is fast enough to finish the track quickly, but slow enough to ensure the robot can read sensor data correctly, avoid hitting obstacles, and take corners safely without slipping or losing balance. ## 4.5 Wheel geometry and track width The parts mounted on the wheels directly control the direction of the robot. We added bearings inside these wheel parts to help the wheels spin smoothly with low friction. The geometry of our wheel placement, combined with the differential, ensures that the wheels can successfully cover different distances without slipping when the robot turns. ## 4.6 Iterations the mechanical side went through before settling on the current shape We tried a few designs before finishing the robot. In one older design, the battery and motor were placed on the first level, but this made the robot too tall. Being too tall caused balance problems during turns and made it hard to place the sensors at the right height from the ground. This led us to our final compact, 2-level design. --- # 5. Power & Electronics ## 5.1 LiPo Selection / Battery Size Most critique elements while selecting LiPo size was electronic cards (Raspberry Pi 5 and STM32 - 5V 5A) and motors (ES08MA II - 5.4/6.5V, 6V 150RPM DC). Also because of the size, we've couldn't get much higher than 2S ~1500 mAh and the most suitable commercial LiPo battery was 2S 1350 mAh, which we've used. ## 5.2 Regulators, Rails and General Circuit Logic Because we use a (nominal) 7.4V battery, we had to decrease the voltage at nearly any rail in our circuit. Our circuit consists of 4 power rails as: * Raspberry Pi Rail (5V 5A): Raspberry Pi 5, Camera Module * Servo Motor Rail (5.4V): Servo Motor * DC Motor Rail (6.4V): DC Motor Driver, DC Motor * STM32 & Sensors Rail (5V): STM32F410RB Nucleo, TFMini-S LiDAR (x3), Adafruit 4754 9-DoF IMU While drawing the circuit diagram, we've considered the electrical noise that motors and battery cause could damage the data coming from precise sensors like TFMini-S LiDARs and IMU. Because of this, the "Star Grounding" technique was used to minimize it without adding any new components / materials to robot. The wiring scheme can be found in `schemes/`. --- # 6. Sensors ## 6.1 Pi Camera Module 3 Wide Every colour decision goes through this camera: orange/blue corner lines, magenta parking-bay walls, red/green traffic pillars. Exposure, gain and white balance are locked at startup from `config.py` so venue lighting cannot drift our HSV thresholds mid-run. ## 6.2 TFMini-S LiDARs (×3) Front, left and right. The two side LiDARs feed the wall-following PD; the front one triggers corner pre-emption. All three run in **I²C mode** at addresses 0x10 / 0x11 / 0x12, sharing I²C1 with the IMU. Originally we had each one on its own UART with DMA — moving to I²C freed up three USARTs and three DMA streams. ## 6.3 IMU (Adafruit LSM6) LSM6 gyro + accelerometer; no magnetometer wired. We integrate gyro Z for heading. `filters.c` keeps a complementary filter for API stability, but `COMP_FILTER_ALPHA = 1.0f` collapses it to pure gyro integration. Indoors, with motor current right next to the breakout and a metal table under the track, magnetometer readings were useless — gyro drift is bounded inside one run and we re-zero between rounds. --- # 7. Software Architecture ## 7.1 Overview Slow, image-heavy, decision-making code on the Pi. Fast, deterministic, hardware-poking code on the STM32. They only talk through one small, well-defined contract. ## 7.2 Raspberry Pi Side — `src/pi/` All Python, tuneable numbers stripped out into `config.py`. ### 7.2.1 Module Map | File | What it does | | ------------------ | ------------------------------------------------------------------ | | `main.py` | Entry point; wires every module and runs the 30 Hz loop. | | `config.py` | Every tuneable number — HSV ranges, PD gains, speeds, thresholds. | | `camera.py` | Owns the Pi Camera, serves the latest frame. | | `vision.py` | HSV colour segmentation, returns detected objects. | | `lane.py` | Orange/blue corner lines, infers driving direction. | | `navigator.py` | PD wall-follow plus pillar avoidance. | | `state_machine.py` | Tracks what the car is doing and decides when to switch. | | `serial_comm.py` | UART link to the STM32. | | `logger.py` | Structured logging. | ### 7.2.2 Main Loop (30 Hz) Read telemetry → check staleness & battery (ESTOP if bad) → grab frame → vision → navigator → state machine → send `CMD_SPEED` + `CMD_STEER`. ### 7.2.3 Vision — `vision.py` HSV segmentation for red, green, magenta, orange and blue; the morphological kernel is pre-built in `__init__`. We avoided going down the deep-learning route — the colours are saturated and the lighting is broadly controlled, so HSV is faster, cheaper, and tunable in five minutes during inspection. Two things worth knowing: **red wraps around in HSV** (H≈0 and H≈180) so we OR two masks together; and we keep magenta and red apart by pushing magenta's V floor to 120 and capping red at H=178. ### 7.2.4 Navigator — `navigator.py` Base controller is plain PD on the LiDAR difference: ``` error = (tof_right - tof_left) - CENTER_OFFSET steering = Kp * error + Kd * d(error)/dt ``` When the front LiDAR sees a wall closer than `CORNER_THRESHOLD_MM` we swap to aggressive corner gains and drop to `SPEED_CORNER`. If the STM32 ever reports a ToF of 0 we substitute the last valid reading — without that, one bad frame would briefly tell the controller it was glued to a wall. In obstacle mode we add a pillar-avoidance term: the pillar centroid says which way to bias, the pillar area gives a rough distance, and the two controls blend by distance. If we are already on the rule-required side we skip the avoidance term entirely — added after the car was weaving even on pillars it would have passed cleanly. ### 7.2.5 State Machine — `state_machine.py` Plain enum FSM. States: `IDLE`, `DRIVING`, `AVOIDING`, `REVERSING`, `PARKING_SEARCH`, `PARKING_EXECUTE`, `STOPPING`, `STOPPED`. Every transition goes through one `_transition()` that timestamps the log. - **Lap counting.** Corner = gyro rate above threshold for `GYRO_CORNER_MIN_MS`, cross-checked against the camera lines; falls back to gyro-only after `CORNER_LINE_FALLBACK_S × 2`. Four corners = one lap. - **Reversal (Obstacle).** On lap 2 after a red pillar: integrate `|gyro_z|·dt` while reversing, exit at 180° ± tolerance, hard `REVERSAL_TIMEOUT_S`. - **Parking.** Four phases — OVERSHOOT, REVERSE_IN (~45° yaw), COUNTER (~40° back), NUDGE until the two side LiDARs agree within `PARKING_ALIGN_TOL_MM`. Any phase aborts if a side LiDAR drops below `PARKING_CLEARANCE_MIN`. ### 7.2.6 Serial Comm — `serial_comm.py` Two threads. **TX** is latest-wins (`dict[int, int]` keyed by `msg_type`) at 50 Hz — a sluggish UART cannot build a backlog of stale steering commands. **RX** parses telemetry, attaches `rx_monotonic`, validates CRC; bad-packet count is logged every 5 s if non-zero. ## 7.3 STM32 Side — `src/stm32/` STM32CubeIDE project. Target is the **NUCLEO-F410RB** at **100 MHz** (HSE 8 MHz BYPASS → PLL M=8 N=200 P=2; F410 maximum). ### 7.3.1 Module Map | File | What it does | | ----------------- | ------------------------------------------------------------------------------------- | | `main.c` | HAL/clock/peripheral init, then super-loop. | | `sensors.c` | LSM6 IMU and 3× TFMini-S, all on I²C1, polled from the 200 Hz tick. | | `motor_control.c` | Open-loop, signed speed in, PWM on TIM5 + DIR pin out. | | `servo.c` | Steering angle → 960–2040 µs pulse on TIM1 CH1. | | `comm.c` | USART1 single-byte RX interrupt + ring buffer; telemetry TX at 100 Hz. | | `safety.c` | Command watchdog and start-button latch. | | `filters.c` | Moving average for LiDARs, complementary filter (currently gyro-only). | | `protocol.h` | Shared packet definitions. | ### 7.3.2 Super-Loop Timing No RTOS, no `HAL_Delay` on the control path; millisecond accumulators. | Task | Rate | | ---------------------------------------------------------- | ---------- | | `sensors_poll()` + `motor_update_output()` | 200 Hz | | `comm_send_telemetry()` (blocking UART TX) | 100 Hz | | `safety_check()` (command watchdog) | 1 Hz | | `comm_process_rx()` | Continuous | ### 7.3.3 Sensors — `sensors.c` LSM6 (0x6A) and three TFMini-S (0x10/0x11/0x12) all share I²C1 (PB6/PB7 at 400 kHz). LiDARs were switched out of UART mode and given those addresses once during bring-up. `sensors_poll()` runs every 200 Hz tick: read the IMU **every call** (6-byte burst from `OUTX_L_G`, scaled by ±250 dps sensitivity), then read **one LiDAR per call** round-robin (~66 Hz each, still well above the 30 Hz Pi loop). For each LiDAR we write the 5-byte read command and receive the 9-byte `0x59 0x59 …` frame, validate, convert cm → mm, push through a `MovingAvg`. A dropped frame leaves the previous value untouched — no spurious zeros. Deliberately **no UART DMA** in the sensor path. ### 7.3.4 Motor Control — `motor_control.c` Open-loop, 200 Hz. Signed speed clamped to ±500, mapped to PWM duty on **TIM5 CH1 (PA0)** at ~20 kHz, with PB0 as direction. Note for porting: TIM2 does not exist on the F410 — motor PWM has to live on TIM5. ### 7.3.5 Servo — `servo.c` Maps degrees → pulse on **TIM1 CH1 (PA8)**: 1 MHz timer clock, 50 Hz frame, centre 1500 µs, 18 µs/deg, ±30°. Updated **immediately** inside `comm.c` when a `CMD_STEER` arrives — there is no servo task in the super-loop. ### 7.3.6 Comm — `comm.c` Pi link is **USART1 (PA9/PA10) at 115200 8N1**. No UART DMA. RX is **single-byte interrupt** (`HAL_UART_Receive_IT`) into a 128-byte ring buffer; `comm_process_rx()` runs continuously, finds the `0xAA 0x55` header, validates CRC8-MAXIM, dispatches: | Command | Action | | --------------- | ---------------------------------------------- | | `CMD_SPEED` | `motor_set_speed_command(value)` | | `CMD_STEER` | `servo_set_angle(value)` — applied immediately | | `CMD_MODE` | Store `g_challenge_mode` | | `CMD_ACK_START` | No-op; refreshes watchdog timestamp | | `CMD_ESTOP` | `motor_estop()`, zero the targets | `comm_init()` seeds `g_last_cmd_tick` so the watchdog does not trip at boot. Telemetry is packed and sent at 100 Hz with a blocking `HAL_UART_Transmit` (22 bytes ≈ 1.9 ms at 115200, well inside the 10 ms slot). ### 7.3.7 Safety — `safety.c` - **Command watchdog** at 1 Hz: if no Pi command in `WATCHDOG_TIMEOUT_MS` (500 ms), call `motor_estop()` and toggle the LED. - **Start button** EXTI on PC13, falling edge with pull-up; `safety_btn_irq()` debounces by re-reading the pin, latches `s_started`, turns the LED on. - **No battery monitoring.** The board is fed regulated 5 V from a buck converter, so the STM32 has nothing to police. `g_battery_mv` is kept at 0 in the telemetry struct so the wire format does not change; the Pi treats 0 as "unknown". ### 7.3.8 Filters — `filters.c` `MovingAvg` for each LiDAR; `CompFilter` kept for API stability but with `ALPHA = 1.0` it collapses to pure gyro integration (see §6.3). ### 7.3.9 Protocol — `protocol.h` Shared packet definitions. Command packets are 7 bytes (header + type + length + int16 + CRC), telemetry packets are 22 bytes. --- # 8. Communication Protocol ## 8.1 Packet Format One UART line, 115200 baud, binary packets, every packet ends in a CRC8-MAXIM byte over everything before it. Two distinct frame headers stop the two directions ever getting confused. ``` Pi → STM32: [0xAA, 0x55, msg_type, payload..., crc8] STM32 → Pi: [0xBB, 0x66, msg_type, payload..., crc8] ``` ## 8.2 Command Types | Byte | Name | What it does | | ------ | --------------- | ---------------------------------------------------- | | `0x01` | `CMD_SPEED` | Signed speed in the Pi's internal units. | | `0x02` | `CMD_STEER` | Steering angle in degrees. | | `0x03` | `CMD_MODE` | 0 = open, 1 = obstacle. | | `0x04` | `CMD_ACK_START` | Pi acknowledges the start button. | | `0xFF` | `CMD_ESTOP` | Drop everything. | ## 8.3 Telemetry `TELEM_SENSORS = 0x10`, 100 Hz. Keys: `tof_front`, `tof_left`, `tof_right`, `imu_yaw`, `imu_pitch`, `imu_roll`, `gyro_z`, `gyro_z_raw`, `battery_mv`, `status_flags`, `started`, `rx_monotonic`. `battery_mv` is always 0 — see §7.3.7. ## 8.4 Latest-Wins TX Pi TX thread runs at 50 Hz draining a `dict[int, int]` keyed by `msg_type`. Latest-wins prevents the unbounded-queue failure mode. Both sides validate CRC8-MAXIM and drop bad packets. --- # 9. Obstacle Management & Strategy - **Open Challenge.** Wall-following the whole way, with front-ToF corner pre-emption. Lap counting from the gyro, cross-checked with the camera lines. - **Obstacle Challenge.** Same wall-follower as the spine; vision tags pillars red/green and gives centroids; navigator turns that into an avoidance term blended by distance; FSM remembers `last_pillar_color` for the lap-2 reversal; after the laps it moves to `PARKING_SEARCH` → `PARKING_EXECUTE`. --- # 10. Engineering Decisions & Tradeoffs - **Two boards, not one.** Python timing is not trustworthy at the cycle level — one GC pause on a corner is a crashed run. - **HSV, not a neural net.** The colours are saturated, the lighting is controlled, and we can re-tune an HSV range during inspection. We could not re-train a model. - **Gyro-only orientation.** The magnetometer was too noisy indoors. Drift is bounded over one run and we re-zero between rounds. - **Open-loop motor.** Started with encoders, pulled them out when the camera and ToFs already gave us enough to track progress. Less wiring, less weight, fewer failure modes. - **All tuneables in one file.** No magic numbers in logic files, ever. Pays itself back the first time you have to tune at a venue under time pressure. - **Latest-wins serial TX.** A list-backed queue would silently grow under load. A `dict` keyed by `msg_type` cannot. - **No networking code.** The rules ban every form of radio during a run, so we disable the Pi's radios before inspection and never wrote any networking code to begin with. --- # 11. Testing 46 pytest tests run on a Windows or macOS laptop with no hardware — `tests/conftest.py` stubs `picamera2` and `pyserial` at import time. ```bash python -m pytest tests/ -v ``` | File | What it tests | | ----------------------- | -------------------------------------------------------------------- | | `test_serial_comm.py` | CRC-8/MAXIM, packet build/parse, telemetry parse, `rx_monotonic`. | | `test_navigator.py` | PD wall-follow, corner-speed switch, ToF=0 guard, pillar avoidance. | | `test_state_machine.py` | Lap counting, reversal trigger/integration/timeout, direction lock. | | `test_vision.py` | Red/green/magenta detection, centroid accuracy, magenta/red split. | We leaned on these heavily — checking the logic on a laptop saves hours compared to "edit, deploy to the car, watch it fail". --- # 12. How To Use The Repo ## 12.1 For Judges Start with this page, then `other/` (system description, hardware specs, protocol), then `schemes/` (wiring/power), then `models/` (CAD). `v-photos/` and `t-photos/` cover the vehicle and the team. You can get most of the picture without opening a source file. ## 12.2 For Developers **Pi (`src/pi/`)** — Python 3.11+, OpenCV, picamera2, pyserial, numpy. ```bash python main.py --challenge open # Open Challenge python main.py --challenge obstacle # Obstacle Challenge ``` Start reading at `main.py`, then `config.py`. **STM32 (`src/stm32/`)** — STM32CubeIDE project. Open the `.ioc` for the Nucleo-F410RB, build, flash over USB. Read `protocol.h` next to the Pi's `serial_comm.py` — they are the two halves of the same contract. **Tests** — `python -m pytest tests/ -v` from the repo root, no hardware needed. ## 12.3 Tips - **What** a value is → `config.py` (Pi) or a header `#define` (STM32). - **Why** we did something → `other/` and §10 above, not the code comments. - Read the wiring scheme and the protocol write-up side by side — quickest way to see how one camera frame becomes a steering command and then a wheel turn. - No networking code anywhere. Rules ban all radio during a run, so we disable the Pi's radios before inspection and never wrote any to begin with.