A bare-metal quadcopter flight controller implemented end-to-end using Copper components.
This example demonstrates a complete flight controller running on the MicoAir H743 board (STM32H743), plus a separate host Copper runtime intended for onboard compute. It showcases Copper's ability to run deterministic, real-time control loops on embedded hardware with zero dynamic allocation during runtime.
The deployment has one multi-Copper root, flight_controller.ron, with independent MCU and
compute subsystems. Cargo features select coherent graph fragments inside each subsystem: firmware
and BevyMon use the manual/preview fragments, while sim and end2end select the closed-loop
autonomy fragments and typed Zenoh interconnects.
The simulator runs these same graphs in sim_mode; it replaces external hardware and transport
at the graph boundaries without changing the application topology.
The real camera is enabled by the end2end feature and uses cu_zed::Zed. The simulator starts both subsystem runtimes in one Bevy process, preempts the hardware ZED source, and transfers the normal typed bridge messages through a simulator-owned link. On a deployed system the compute-side Copper Zenoh bridge listens on udp/0.0.0.0:7447 and uses bincode for the two fixed-size messages. Binding a no-std UDP network resource and Zenoh backend on the H743 is intentionally deferred; the firmware graph remains buildable without pretending that transport exists.
- MCU: STM32H743VIT @ 400MHz
- IMU: BMI088 (accelerometer + gyroscope)
- RC Input: CRSF protocol (ExpressLRS compatible)
- ESC Output: BDShot (bidirectional DShot with telemetry)
- VTX/OSD: MSP DisplayPort protocol
- Storage: MicroSD card for logging
- Battery: ADC voltage monitoring
See doc/PINOUT.md for the complete pinout reference.
Manual and AUTO commands join at one MCU-side mode supervisor before the existing cascaded controller:
GNSS + AHRS + heading -> navigation -> round-trip mission -> MCU context bridge
|
v
ZED -> ViTFly (compute)
|
v
RC Input -> RC Mapper -------------------------------> mode supervisor -> attitude -> rate -> mixers
\-> AUTO mission/controller <- MCU command bridge ---/
| Task | Description |
|---|---|
bmi088 |
BMI088 IMU driver (accelerometer + gyroscope) |
imu_cal |
Gyroscope bias calibration on arm |
ahrs |
Attitude and Heading Reference System |
mapper |
RC channel mapping and arm/mode logic |
navigation |
Latches GNSS, AHRS, and true heading into a freshness-checked navigation state |
auto_mission |
Latches home and a point 500 m ahead / 8 m up, then alternates forever |
autonomy_context |
Publishes the fixed-size ViTFly context at approximately 30 Hz |
auto_controller |
Tracks fresh ViTFly world-frame [north, west, up] commands toward the current waypoint |
mode_supervisor |
Selects manual or AUTO controls before the cascaded flight controller |
attitude |
Outer loop PID (angle to rate setpoint) |
rate |
Inner loop PID (rate to motor commands) |
mixer0-3 |
QuadX motor mixing |
battery_adc |
Battery voltage monitoring |
vtx_osd |
MSP DisplayPort OSD rendering |
led_blink |
Status LED heartbeat |
- Angle: Self-leveling mode with attitude hold
- Acro: Rate mode for aerobatic flight
- Position Hold: (placeholder for GPS integration)
- Auto: SC middle position. Each entry latches the current position/heading, creates a target 500 m ahead at current MSL altitude + 8 m, and alternates between that point and home forever. SC high remains reserved for forced recovery.
- Airmode: Maintains control authority at zero throttle for aerobatic maneuvers
- Expo curves: Configurable stick expo for smoother control
- Gyro calibration: Automatic bias calibration on arm
- Zero-copy logging: Binary logs to SD card via unified logger
just dagThis renders the complete simulated deployment using the default mission. Both selectors remain
overridable, for example with just dag mission=flow or just dag features=end2end.
just subsystems-checkThis checks the real ZED/ViTFly/Zenoh compute graph and the current buildable STM32 firmware graph. The autonomy MCU graph becomes the firmware entrypoint once its no-std UDP resource and bridge backend are bound.
To compile or run the real ZED-enabled compute graph:
just end2end-check
just end2end# Build and flash with text logging (debug)
just fw
# Build and flash release (optimized, no text logs)
just fwr
# Build and flash debug profile
just fwd# Extract CopperLists from log file
just logreader log=logs/embedded.copper
# Check log file integrity
just fsck log=logs/embedded.copper
# Extract text logs (requires log index)
just textlogs log=logs/embedded.copperTo replay a recorded simulation log through the remote debug API:
cd examples/cu_flight_controller
just resim
# or, for parity with other examples:
just resim-debugTo inspect the compute-side log in Time Traveler—including the depth raster that was actually passed to ViTFly—run:
# Generate a flight_compute_sim.copper log, then exit the simulator.
just sim
# Serve that compute-side log to Time Traveler.
just compute-resimThen attach Time Traveler to
copper/examples/cu_flight_controller/compute/debug/v1.
The replay target uses the standardized replay contract:
--debug-baseselects the remote debug namespace--log-baseselects the recorded Copper log to inspect--replay-log-baseselects the per-session replay log template
If you are building another replay target, follow the same pattern with
cu29::replay::{ReplayCli, ReplayDefaults, per_session_replay_log_base, serve_remote_debug}.
You can use the PyO3 bindings to iterate CopperLists directly from Python and extract GNSS fields without going through JSON.
This is an offline log-analysis workflow, not a runtime Python task. Python only touches data that Copper has already recorded, so this does not affect the realtime behavior of the flight controller itself.
# Build the Python extension module
just py-build
# Print GNSS latitude/longitude from the flight-controller sim log
just py-gnss log=logs/flight_controller_sim.copperThe script is at python/print_gnss_from_log.py and can also be run directly:
python3 python/print_gnss_from_log.py logs/flight_controller_sim.copperImplementation notes:
src/python_module.rsexposes an app-specific#[pymodule]- it uses
gen_cumsgs!("mcu_config.ron")so the CopperList type matches this app - the Python script imports that module and iterates typed CopperLists plus runtime lifecycle records
This pattern is the recommended Python story in Copper: post-process logs in Python after the run, keep Python off the control path during the run.
# Test RC input via joystick
just rc# Run the normal full-window simulator with MCU + compute Copper runtimes
just
# Run the split BevyMon simulator
just bevy
# Run the split BevyMon simulator in the browser
just web
# Build a deployable browser bundle into dist/flight-controller with hashed asset filenames
just web-distThe split BevyMon path reuses the same cu_bevymon::spawn_split_layout(...) shell as
cu_rp_balancebot and cu_bevymon_demo, but the left panel still runs the real flight-controller
sim world, OSD, and help overlays.
The simulated compute subsystem publishes ZED2i-compatible 320×240 stereo RGBA images at a 100°
vertical field of view, matching the 4:3 projection used to train and evaluate ViTFly. It also
publishes depth and confidence maps, calibration and rig transforms, plus IMU, magnetometer,
barometer, and frame metadata. Bevy performs the camera rendering and GPU depth readback; the
simulator injects those buffers into the preempted cu_zed::Zed source. The compute graph runs
ViTFly inference on the CPU by default and draws its predicted velocity over the top-right depth
inset. Use just sim-cuda to run the same graph with ViTFly inference on NVIDIA CUDA.
The simulator has mutually exclusive forest-world and urban-world features. forest-world is
the default for cargo run and just sim; use just sim-urban for the original city scene. The
equivalent Cargo commands are:
cargo run
cargo run --no-default-features --features sim,urban-worldThe forest is a deterministic 635 m hilly world built from the CC0 Kenney Nature Kit. Its source
GLBs, license, editable Blender scene, final GLB, and spawn-point preview live under assets/.
Regenerate the scene with:
just forest-assetsBoth world GLBs are resolved through the same Copper CDN asset path as the quadcopter and lighting assets. Atmospheric fog is enabled only for the forest observer camera, so the simulated ZED depth camera continues to measure scene geometry without a cosmetic fog layer. Each world feature carries its own scale, bounds, spawn position, and spawn yaw.
CPU runs use RAYON_NUM_THREADS=8 from the checked-in .cargo/config.toml. Making the pool size
explicit prevents Candle from repeatedly probing Linux CPU topology while retaining parallelism for
Bevy and Avian. The setting is scoped to this example, and an existing shell value takes precedence.
The simulator reads RC input from a host joystick device (evdev). By default it only auto-connects to
radio-style joystick profiles, to avoid false positives from keyboards/mice/gamepads that also expose joystick interfaces.
Environment variables:
# Prefer a specific device name substring (case-insensitive)
CU_SIM_JOYSTICK="radiomaster" just sim
# Allow generic/non-radio joystick devices as RC input
CU_SIM_ALLOW_GENERIC_JOYSTICK=1 just simNotes:
- If no compatible RC joystick is found, the sim falls back to keyboard controls.
- When connected, the bottom-right help panel shows the selected device name and technical axis bindings (
ABS_X,ABS_RY, etc.). - USB and Bluetooth radios both work if they appear as a joystick device on the host.
Rresets the rigid body, controller/estimator state, AUTO mission, camera cache, and ViTFly recurrent state. With a joystick, move ARM low and AUTO off after resetting before the interlock permits rearming.
- Simulation path: no receiver is used directly; input is read from the host joystick interface.
- Auto-detected TX joystick profiles:
- ExpressLRS-style joystick names (
expresslrs,elrs,radiomaster) - OpenTX / EdgeTX USB joystick names (
opentx,edgetx)
- ExpressLRS-style joystick names (
- ExpressLRS BLE mapping: CH1-CH8 are joystick axes and CH9-CH16 are buttons. The sim uses
CH5 / SA (
ABS_Z) for arm, CH6 / SB (ABS_RZ) for Acro/Angle/Position Hold, and CH7 / SC (ABS_THROTTLE) middle position for Auto; the high position is reserved for forced recovery mode. - Keyboard mapping:
1/2/3select Acro/Angle/Position Hold and4toggles Auto. - Firmware path (real hardware): RC input is CRSF on UART, so use a CRSF-compatible RX link (for example ExpressLRS/Crossfire-class receivers and compatible transmitters).
mcu_config.ron and compute_config.ron contain each subsystem's shared graph exactly once. Their
feature-gated manual/preview and autonomy fragments add only the nodes and connections that differ.
The 500 m / 8 m waypoint geometry and infinite repetition are fixed demo constants, not RON
parameters. Key configurable parameters:
config: {
"kp": 0.04,
"ki": 0.0,
"kd": 0.0005,
"airmode": true,
"airmode_start_throttle_percent": 20.0,
}config: {
"angle_limit_deg": 60.0,
"acro_rate_dps": 180.0,
"acro_expo": 0.3,
"kp": 1.0,
}config: {
"arm_channel": 4,
"arm_min": 1700,
"arm_max": 1811,
"mode_channel": 5,
}QuadX configuration (props out):
Front
3 1
\ /
X
/ \
2 0
Rear
| Motor | Position | Rotation |
|---|---|---|
| 0 | Rear Right | CCW |
| 1 | Front Right | CW |
| 2 | Rear Left | CW |
| 3 | Front Left | CCW |
just attachThe firmware logs to the SD card in Copper's binary format. Use the log reader tools to extract and analyze:
# Extract structured data
just logreader
# Extract text logs (when compiled with textlogs feature)
just textlogsKey Copper components used:
cu29- Core runtimecu-ahrs- Attitude estimationcu-bdshot- Bidirectional DShot ESC protocolcu-crsf- CRSF RC protocol (ExpressLRS)cu-msp-bridge- MSP protocol for VTX/OSDcu-pid- PID controllercu-micoairh743- MicoAir H743 HAL bundlecu-logmon- Log monitoring