UAV Ground Control Station for Wilderness Search and Rescue Research
This branch contains the multi-drone backend rewrite. It replaces the original single-drone backend with a fully async, dynamically-scaling architecture that manages an arbitrary number of simultaneous PX4 drones over serial and/or UDP connections.
| Single-Drone (main) | Multi-Drone (this branch) | |
|---|---|---|
| Drone identity | Implicit | drone_id = "drone_{sys_id}" from MAVLink srcSystem |
| Connections | One fixed connection | One per physical device; multiple drones per UDP broadcast |
| State storage | Single flat dict | GlobalState._drone_data[drone_id] keyed per drone |
| Command routing | Direct | CommandHandler looks up drone_registry[drone_id] |
| Fleet commands | N/A | START_MISSIONS, STOP_MISSIONS, RTL_ALL |
| Joystick control | Implicit | Targeted to selected_drone_id |
DroneManager lifecycle |
Static | Spawned on first message from a new sys_id; self-terminates after 1 s of silence |
| AI chat | Single drone | Returns per-drone command sequences for the whole fleet |
| Telemetry broadcast | Per-drone | Full fleet state in one telemetry push every 200 ms |
Frontend (React)
│ WebSocket /ws
▼
┌──────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ │
│ websocket_server.py ──► CommandHandler │
│ (broadcast loop) │ │
│ ▼ │
│ drone_registry │
│ { drone_id: DroneManager } │
│ ▲ │
│ │ spawn on new sys_id │
│ ConnectionManager │
│ (device scan every 2 s) │
└──────────┬───────────────────────────────────────────────┘
│ MAVLink (serial / UDP)
┌──────┴──────────────────────────┐
│ Serial port(s) UDP port(s) │
│ drone_1 drone_2 │
│ drone_3 drone_4 ... │
└─────────────────────────────────┘
All frontend ↔ backend communication uses a single /ws WebSocket endpoint. Messages are JSON with command (client→server) or messagetype (server→client).
multi-drone backend/
├── main.py # Entry point, wires all subsystems
├── requirements.txt
│
├── api/
│ ├── websocket_server.py # FastAPI app, /ws endpoint, telemetry broadcast loop
│ └── command_handler.py # Routes WS commands to per-drone DroneManager instances
│
├── core/
│ ├── connection_manager.py # Device discovery, MAVLink connections, DroneManager spawning
│ ├── mission_planner.py # Lawnmower/boustrophedon survey pattern generator
│ ├── chat_completion.py # OpenAI GPT-4o integration for natural-language commands
│ ├── joystick_manager.py # Pygame gamepad polling → shared_state["joystick_pose"]
│ ├── controller_config.json # Axis calibration (min/center/max) for physical gamepad
│ └── logging_conf.py # Rotating file logs (debug.log, info.log, daily rotation)
│
├── drones/
│ ├── drone_manager.py # Per-drone MAVLink message handler and mission state machine
│ └── drone_commands.py # MAVLink command functions (arm, takeoff, mode, mission, etc.)
│
└── shared/
├── state.py # GlobalState singleton — thread-safe per-drone telemetry store
├── db.py # SQLite persistence (settings, UDP ports)
├── constants.py # Mode mappings, MAV_STATE, MAV_SEVERITY, param types
└── utils.py # haversine_distance for position history throttling
- Python 3.10+
- One or more PX4 drones or SITL instances (see px4-gazebo-headless for Docker/WSL SITL)
cd "multi-drone backend"
python -m venv venv
.\venv\Scripts\activate
pip install -r requirements.txtCreate a .env file in multi-drone backend/:
OPENAI_KEY=your_openai_api_key_hereUDP ports are persisted in SQLite (settings table, key udp_ports). The default is [14550]. You can update this at runtime via the UPDATE_SETTINGS WebSocket command.
.\venv\Scripts\activate
python main.pyThe server starts on http://0.0.0.0:8000. The WebSocket endpoint is ws://localhost:8000/ws.
ConnectionManager scans for devices every 2 seconds:
- Serial ports — enumerates ports matching "USB", "Silicon Labs", or "UART" on Windows;
/dev/ttyUSB*and/dev/ttyACM*on Linux/macOS - UDP — opens
udp:0.0.0.0:<port>for each port in theudp_portssetting (default: 14550)
For each device, it opens a MAVLink connection and waits up to 3 s for a heartbeat. On success, a background reader loop forwards every message to its DroneManager, keyed by the MAVLink srcSystem ID:
sys_id 1 → drone_registry["drone_1"] → DroneManager
sys_id 3 → drone_registry["drone_3"] → DroneManager
...
Multiple drones can share a single UDP broadcast channel. DroneManager instances are created dynamically on the first message from a new sys_id and self-terminate after 1 second of silence.
messagetype |
Content |
|---|---|
telemetry |
{ "drone_1": {...state}, "drone_3": {...state} } — full fleet state at ~5 Hz |
settings |
{ udp_ports, selected_drone_id, joystick_pose, joystick_connected } |
waypoints |
Generated lawnmower waypoints for a polygon |
selected_drone |
Confirmation of active drone selection |
chatmessage |
GPT-4o response with message, confirmation, and per-drone drone_commands |
error |
Error description string |
All commands are JSON with a command field and a drone_id field.
Single-drone commands (drone_id: "drone_N"):
command |
data fields |
Description |
|---|---|---|
ARM_DISARM |
— | Toggle arm/disarm based on current state |
TAKEOFF_LAND |
— | Toggle takeoff/land based on landed_state |
SET_MODE |
mode |
Set PX4 flight mode (e.g. "Loiter", "Mission") |
RTL |
— | Return to launch |
CLEAR_MISSION |
— | Clear onboard mission |
UPLOAD_MISSION |
waypoints, altitude |
Upload a list of lat/lon waypoints |
SET_PARAM |
param_id, param_value, param_type |
Write a MAVLink parameter |
CREATE_LAWNMOWER |
polygon, spacing |
Generate and return a lawnmower survey pattern |
BATCH_COMMANDS |
commands[] |
Sequenced commands with state-polling waits between steps |
Fleet-wide commands (drone_id: "all"):
command |
Description |
|---|---|
START_MISSIONS |
Set all drones to Mission mode |
STOP_MISSIONS |
Set all drones to Loiter mode |
RTL_ALL |
RTL every drone |
GCS system commands (drone_id: "gcs"):
command |
data fields |
Description |
|---|---|---|
UPDATE_SETTINGS |
udp_ports |
Persist UDP port list to SQLite |
SELECT_DRONE |
drone_id |
Set active drone for joystick control |
SEND_CHAT |
message |
Send natural-language prompt to GPT-4o |
Each drone's state dict in the telemetry broadcast contains:
| Field | Source message |
|---|---|
mode, system_status, autopilot |
HEARTBEAT |
global_lat, global_lon, global_alt |
GLOBAL_POSITION_INT |
global_position_history |
Appended when drone moves >1 m |
local_x, local_y, local_z |
LOCAL_POSITION_NED |
roll, pitch, yaw |
ATTITUDE |
fix_type, satellites_visible, vel |
GPS_RAW_INT |
voltage_battery, current_battery, battery_remaining |
SYS_STATUS |
landed_state |
EXTENDED_SYS_STATE |
home_lat, home_lon, home_alt |
HOME_POSITION |
airspeed, groundspeed, heading, throttle, climb |
VFR_HUD |
parameters |
PARAM_VALUE (all params, type-converted) |
messages |
STATUSTEXT (appended) |
mission_current |
MISSION_CURRENT |
uploaded_mission |
MISSION_ITEM / MISSION_ITEM_INT |
command_ack |
COMMAND_ACK (appended) |
BATCH_COMMANDS allows sequenced multi-step operations with automatic state-polling waits between each step. Each step specifies a command and optionally a wait_for condition:
{
"command": "BATCH_COMMANDS",
"drone_id": "drone_1",
"data": {
"commands": [
{ "command": "ARM_DISARM" },
{ "wait_for": "ARMED" },
{ "command": "TAKEOFF_LAND" },
{ "wait_for": "FLYING" }
]
}
}Supported wait_for states (polls every 100 ms, 10 s timeout):
| State | Condition |
|---|---|
ARMED |
system_status == 4 |
DISARMED |
system_status == 3 |
FLYING |
landed_state != 1 |
LANDED |
landed_state == 1 |
MODE |
Current mode matches target |
MISSION_CLEARED |
uploaded_mission == [] |
SEND_CHAT sends the user's message to GPT-4o along with a summary of the current fleet state. The model returns a structured JSON response:
{
"message": "Taking off drone_1 and drone_3.",
"confirmation": "Arm both drones then take off to 10 m.",
"drone_commands": {
"drone_1": { "commands": [{ "command": "ARM_DISARM" }, { "command": "TAKEOFF_LAND" }] },
"drone_3": { "commands": [{ "command": "ARM_DISARM" }, { "command": "TAKEOFF_LAND" }] }
}
}The frontend presents message/confirmation to the user and, on approval, dispatches each drone_commands entry as a BATCH_COMMANDS message.
JoystickManager polls a physical gamepad via pygame every 50 ms and writes scaled axis values to shared_state["joystick_pose"]. ConnectionManager then sends MANUAL_CONTROL MAVLink messages to the currently selected drone at 20 Hz.
Axis calibration (min/center/max raw values) is stored in core/controller_config.json and reloaded every 500 ms — changes take effect without a restart.
To select which drone the joystick controls, send:
{ "command": "SELECT_DRONE", "drone_id": "gcs", "data": { "drone_id": "drone_1" } }CREATE_LAWNMOWER generates a boustrophedon (back-and-forth) survey pattern over an arbitrary polygon:
{
"command": "CREATE_LAWNMOWER",
"drone_id": "drone_1",
"data": {
"polygon": [[lon, lat], [lon, lat], ...],
"spacing": 20
}
}The planner converts the polygon to UTM, finds the minimum bounding rectangle to determine the optimal sweep angle, generates four candidate patterns, selects the one whose first waypoint is nearest to the drone's home position, and returns the result as a waypoints WebSocket message.