flying an RC plane from my laptop keyboard, over LoRa.
this started as the final project for my C programming class (ECE 22900 at PFW). everything had to be written in C, and by the time the semester ended I liked it too much to let it sit, so I've kept building on it since.
three programs, all C, talking through one shared packet format:
controller/runs on the laptop. reads the keyboard in raw mode, packs the control state into a 13-byte packet, and ships it out over USB serial. there's a single live status line in the terminal showing throttle, control surfaces, round-trip time, and dropped packets.firmware_bridge/runs on a Heltec V3 (ESP32-S3 + SX1262) plugged into the laptop. it takes packets off the USB UART and retransmits them over LoRa at 915 MHz. it also timestamps every packet it sends and waits for the plane to echo it back, which is how the laptop knows the real round-trip latency.firmware/runs on a second Heltec V3 mounted on the plane. receives packets, drives the servos and ESC with PWM, mirrors the link state on the little OLED, and echoes packets back for the latency measurement.
the wire format and CRC live in common/packet.c so the laptop and both boards parse the exact same bytes. no chance of the three drifting apart.
13 bytes, little endian:
| bytes | field | notes |
|---|---|---|
| 0 | magic | 0xA5 sync byte |
| 1 | seq | wraps every 256 |
| 2-3 | throttle | 0..1000 |
| 4-5 | aileron | -500..500 |
| 6-7 | elevator | -500..500 |
| 8-9 | rudder | -500..500 |
| 10 | flags | bit0 ARMED, bit1 LED |
| 11-12 | crc | CRC-16-CCITT over bytes 0..10 |
the plane drops anything with a bad magic byte, a bad CRC, or a stale sequence number. if 500 ms pass without a good packet it fails safe: throttle to zero, surfaces neutral. that part got tested a lot (on the bench, on purpose, mostly).
round trip from keypress to servo and back is usually 25 to 35 ms at SF7 / 500 kHz bandwidth. about 14 ms of that is just LoRa time on air for the 13-byte payload, the rest is UART and processing. good enough to feel immediate.
laptop side is plain make:
cd controller
make
./controller # /dev/ttyUSB0 by default
./controller --dry-run # no serial, handy for testing keys
flags: --port DEV, --log PATH (CSV flight log), --rate HZ, --dry-run.
both firmwares are ESP-IDF v5.3 projects:
cd firmware # or firmware_bridge
idf.py build
idf.py -p /dev/ttyUSB0 flash
pin defaults match the Heltec V3. if your board differs, the #defines are at the top of each main/*.c.
WASD-ish. edit BINDINGS[] at the top of controller/src/main.c to remap.
| key | action |
|---|---|
| W / S | throttle up / down |
| Q / E | aileron |
| Space / LShift | elevator |
| A / D | rudder |
| G / B | arm / disarm |
| T | trim reset |
| L | toggle plane LED |
| Esc | quit |
- 2x Heltec WiFi LoRa 32 V3
- standard 9g hobby servos (SG90 etc.) on 5 V, signal straight from the ESP32 GPIO
- any servo-PWM ESC for the motor
the plan is to close the loop: IMU and barometer on the plane, and eventually let it hold position on its own instead of me mashing WASD. the link layer here is the foundation for that.