Mullet: ground control station for drone swarms
Operators plan, launch and monitor drone missions from one browser tab: a live 3D map of the fleet, a mission builder, and a drone that refuses anything unsafe with a reason.
Problem
Off-the-shelf ground stations like QGroundControl are built for one drone at a time. Running coordinated missions with several drones in Offboard mode needed a tool built for swarms.
Approach
- Browser → FastAPI gateway → ROS 2 → mullet_core → PX4. Each drone runs its own supervisor (mullet_core) that owns arming, Offboard, takeoff, land and hold.
- Drones announce themselves with a 10 Hz heartbeat snapshot; the gateway discovers the fleet from it, so nothing is configured per drone.
- A mission framework on the drone (the ergodic MPPI planner, for now) receives the mission, checks it and streams setpoints; the ground station only draws and supervises.
What I built
- Backend in FastAPI that bridges the operator to the fleet: telemetry over WebSocket, commands and mission upload through ROS 2 actions and services.
- React/TypeScript frontend with a live 3D map of every drone: heading arrow, id, a 2-minute trail, and per-drone control (takeoff, start mission, hold, return home, land).
- Mission builder on the map: draw the area, obstacles and target density in 2D or 3D. Real buildings become obstacles at their mapped height.
- Offline field mode: download the basemap and terrain for a site ahead of time, then build and fly without connectivity.
- Tested against a PX4 SITL fleet through the same gateway a real drone uses, with no mock mode.
Safety
- The browser never talks to PX4. It only calls semantic endpoints, and the gateway refuses Start mission until the drone’s planner reports READY.
- Every mission is preflighted on the drone before it can start: spec validation, a timing check against a 16 ms control deadline, and a 20 s dry run that must stay inside the area and the safety margins. A refusal comes back to the operator with its reason.
- The area polygon is the geofence, obstacles are inflated by the drone’s stopping distance, and crossing the ceiling holds the drone.
- A stale command stream is ignored and the drone holds position; after 2 s it drops to HOLD. Velocity and acceleration are clamped on the drone, not in the UI.
- The pilot always wins: switching out of Offboard on the RC, or the kill switch, is honoured and the supervisor never fights it. Land works from any state.
Results
- Used in field operations at Skymule.
- Drone state at 10 Hz per drone: FSM state, PX4 mode, position, and a capability flag per command, so the UI only offers what the drone will accept.
- Missions flown from the builder track the plan within 0.18 m (p95) in simulation-in-hardware at 1.5 m/s.
Stack
- Backend
- FastAPI, rclpy action and service clients, WebSocket
- Frontend
- React, TypeScript, Vite, MapLibre GL (3D terrain and buildings)
- Vehicle side
- ROS 2 Jazzy, C++ supervisor, PX4 1.17 Offboard over uXRCE-DDS
- Maps
- OpenFreeMap vector tiles and AWS terrain tiles, cached by the gateway
- Dev loop
- Docker, PX4 SITL fleet, pytest
Code is proprietary; happy to walk through the architecture in an interview.