currently still a work in progress, building a (very) mini quadcopter for esp32 on bare metal with embassy.dev. right now using the esp32-c3 supermini, I may upgrade this to the s3 if the extra core is necessary.
The frame stl & 3mf files are in the stl/ dir and can be used to 3d print the frame. I created them in OnShape. For the battery compartment, I opted for a friction fit with pegs that you can also glue in. This got around some issues with printing supports that were a pain to remove.
There's a top stand that serves as a mount for the microcontroller and MPU6050 or ICM20948. I'll include a full parts list as the project progresses.
I've also created a prop guard you can mount as a single piece or in two halves, see the 3mf file in stl/
They have a C-snap fit to the motor holders that should lock them into place pretty well. I've printed with a 0.4 and 0.6 nozzle on a bambu a1 and both results are usable. Obviously if you want cleaner layer lines go for the smaller nozzle.
I've printed in PLA, PETG and PETG-CF & GF. PLA seems totally fine, the drone frame is around 16 grams. The props are heavy at an additional 16 grams. They might require a bit of re-design but for indoor testing not so bad.
Currently only building on the c3 and c6. I'll try the s3 if I have time to attempt a build on the xtensa cores but RISC-V is better supported by embassy & Rust. The s3 supermini might be interesting because it's dual-core and has the same footprint as the c3.
| Part | Chip | Cores | Flash | RAM | WiFi | Notes |
|---|---|---|---|---|---|---|
| ESP32-C3 SuperMini | ESP32-C3 | 1× RV32 | 4 MB | 400 KB | 2.4 GHz | Current build - c3 feature |
| ESP32-C6 SuperMini | ESP32-C6 | 1× RV32 | 4 MB | 512 KB | 2.4/5 GHz | Drop-in upgrade - c6 feature |
| ESP32-S3 (16R8) | ESP32-S3 | 2× LX7 | 16 MB | 8 MB PSRAM | 2.4 GHz | Dual-core; needs Xtensa toolchain - see docs/s3-migration.md |
| ESP32-S3 Mini | ESP32-S3 | 2× LX7 | 4/8 MB | - / 2 MB | 2.4 GHz | Compact form factor |
| Part | DOF | Interface | Accel | Gyro | Mag | Notes |
|---|---|---|---|---|---|---|
| ICM-20948 | 9DOF | I²C / SPI | ✓ | ✓ | ✓ AK09916 | Current build - yaw via magnetometer |
| MPU-6050 | 6DOF | I²C | ✓ | ✓ | ✗ | No yaw reference; cheaper and common |
| Part | Qty | Notes |
|---|---|---|
| 8520 brushed motor | 4 | find it on aliexpress like everything else |
| mosfet 100N03A | 4 | One per motor |
| 1S LiPo battery (3.7v) | 1 | 3.7v 1s battery 25C or more discharge rate* |
| Propeller | 4 | 55 or 65mm |
| 3D printed frame | 1 | STL/3MF files in stl/ - designed in OnShape |
Requires espflash for flashing (cargo install espflash).
*I tried with a 503040 3.7v lipo recycled from a keyboard build but the BMS (battery management system) on it will automatically shut off after a few seconds. It's not really build to power these motors.
cargo build -p embassy_quad --target riscv32imc-unknown-none-elf
cargo run -p embassy_quad --target riscv32imc-unknown-none-elfOr using the aliases:
cargo build-c3
cargo flash-c3The C6 uses a different RISC-V target (riscv32imac vs riscv32imc). The
c3 and c6 features are mutually exclusive — always use
--no-default-features when building for C6 to avoid activating both:
cargo build-c6
cargo flash-c6See .cargo/config.toml to see expansion of aliases
DEFMT_LOG is read at compile time by esp-println:
DEFMT_LOG=debug cargo flash-c3THROTTLE_CAP sets the max throttle at compile time, it will map your throttle input from 0-100 over the range you specify. For example, if you set THROTTLE_CAP=50 and push the throttle to 80% it will translate into .8 * 50 = 40%.
to see a 3d rendering of the orientation run:
DEFMT_LOG="info" LOG_RATE_MS=1 cargo flash-c3 --features visualize | (cd visualizer && cargo run)It feeds the esp32 log output to a binary reading stdin and rendering a cube on screen
By default, sensor readings from the ICM-20948 are sent to the ESP32-C3 where either a Madgwick filter fuses accel and gyro data in software to correct orientation or the IMU's DMP is used to get the already fused output (dmp feature is on by default). The ICM-20948 also has an onboard DMP that fuses data directly on the sensor board. Output looks pretty good and doesn't have the yaw drift that the software fusion does when the magnetometer is enabled.
DEFMT_LOG="info" LOG_RATE_MS=1 cargo flash-c3 --features visualize | (cd visualizer && cargo run)The ground_control/ crate is a PC-side sender that reads a gamepad and streams control packets to the drone over UDP.
The drone runs a wifi AP (esp-quad, WPA2) with a static IP of 192.168.4.1. There is no DHCP server, so you must assign yourself a static IP when connecting. The trick is to do this in a single nmcli con add — adding security and static IP separately doesn't work reliably:
nmcli con add type wifi con-name esp-quad ssid esp-quad \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk <same as AP_PASSWORD> \
ipv4.method manual \
ipv4.addresses "192.168.4.2/24" \
ipv4.gateway "192.168.4.1"The AP password is set at build time via the AP_PASSWORD environment variable. If unset, the AP is open (remove the wifi-sec.* lines above).
Once the profile exists, connecting is just:
nmcli dev wifi rescan
nmcli con up esp-quadThen run the ground control sender:
cd ground_control && RUST_LOG="info" cargo run --releaseThe left stick Y axis controls throttle (center = 0%, full up = 100%), left stick X controls yaw, and the right stick controls pitch/roll. The Start button toggles arm. Motors only spin when armed and throttle is above 0.
The drone IP and port can be overridden at build time:
RUST_LOG="info" GATEWAY_IP=192.168.4.1 UDP_PORT=4444 cargo run --releaseflash calibration:
DEFMT_LOG="debug" LOG_RATE_MS=100 AP_PASSWORD="testtest" cargo flash-c3 --features calibrateYou can then either leave the quad plugged in or unplug and calibrate using the wifi outputs, either way, you need to connect ground_control
after the esp-quad network shows up on wifi, connect to it and fire up ground control in calibration mode:
~/dev/rust/drone_embassy calibrate_on_wifi
❯ RUST_LOG="info" cargo gc -- --calibrate
Finished `release` profile [optimized] target(s) in 0.02s
Running `ground_control/target/x86_64-unknown-linux-gnu/release/ground_control --calibrate`
2026-07-19T04:19:17.472588Z INFO ground_control: connected to 192.168.4.1:4444 - waiting to start calibration
2026-07-19T04:19:17.595122Z WARN ground_control: place: level
2026-07-19T04:19:32.976684Z WARN ground_control: place: front side up
2026-07-19T04:19:48.332827Z WARN ground_control: place: back side up
2026-07-19T04:20:03.798211Z WARN ground_control: place: right side up
2026-07-19T04:20:19.373504Z WARN ground_control: place: left side up
2026-07-19T04:20:34.615814Z WARN ground_control: place: upside down
2026-07-19T04:20:50.079828Z INFO ground_control: === CALIBRATION COMPLETE - saved ===follow the instructions and tilt the drone around the axis to get readings. The readings are saved in non-volatile flash memory on the esp32.
Unit tests live in libs, the crate shared between firmware and ground_control that defines the control/telemetry packet wire formats. It's the only crate that can build for the host, firmware can't (esp-hal's build script refuses to build for a non-embedded target), so there's nothing to run tests against there yet.
telemetry and telemetry-verbose each change the telemetry packet's wire format, so they're tested separately:
cargo test -p libs
cargo test -p libs --features telemetry
cargo test -p libs --features telemetry-verboseCheck the docs/ directory for some more info on assembly, setting up different controllers, sensors, etc.
Docs and tests are sometimes generated with the use of LLMs, along with explanation/discovery, but the purpose of this project is to actually learn, so the code is still written by a human (hi!)

