src/routes/drone_routes.py (~1700 lines) — registered under url_prefix="/api/v1/" in src/main.py:223. It is the largest route file in the gateway and splits into three concerns:
- Drone CRUD — create/update/delete/list drones, subscription-gated at creation.
/drone/action/*control commands — ~25 thin JWT handlers that delegate toDroneControlService, which dispatches MAVROS service calls and topic publishes over a pooled rosbridge WebSocket.- Physical-drone bootstrap — two non-JWT endpoints (
/drone/activate,/drone/pull) the drone itself calls to self-provision.
The handlers here are deliberately thin: they parse
drone_id from the JSON body, resolve user_id from the JWT, and call one drone_control_service.<cmd>(...) method. All rosbridge framing, connection pooling, SITL IP translation, and the MAVLink command numbers live one layer down. For dispatch internals see DroneControlService & Rosbridge Dispatch.Auth models on this blueprint
Almost everything is JWT Bearer (@jwt_required()). Two endpoints are exceptions — they authenticate the drone hardware, not a user:
| Endpoint | Auth mechanism | Notes |
|---|---|---|
GET /drone/activate | 10-digit numeric token header | Matched via get_drone_by_token; not JWT. Returns 406 if the token is missing, not 10 digits, or unknown (drone_routes.py:1571-1582). |
GET /drone/pull | check_vpn_ip (source IP) | Trusts a 10.71.x VPN source IP or X-Drone-IP header (src/middleware/drone_vpn.py). See VPN IP Authentication & Jumphost Routing. |
The command path
Drone CRUD
| Endpoint | Method | Success code | Purpose |
|---|---|---|---|
/drone | POST | 201 | Create a drone (subscription-checked, may return 402). |
/drone/{drone_id} | PUT | 201 | Update name / type / mission_id. |
/drone/{drone_id} | DELETE | 201 | Delete a drone (async; tears down S3 assets + Janus room + containers). |
/drones | GET | 201 | List the caller’s drones. |
/drones/mission?missionId= | GET | 201 | List drones assigned to a mission. |
These older routes return raw
jsonify bodies and use 201 for reads and updates (not 200) — e.g. GET /drones returns 201. The current Dashboard depends on these codes; changing them is a breaking change. Newer blueprints use the {success, data|error} envelope instead (see HTTP API Overview & Auth Models).Create a drone (and the 402)
create_drone (drone_routes.py:34) counts the caller’s existing vehicles with get_drone_counts_by_type, then asks subscription_service.can_user_add_vehicle. If the plan does not allow another vehicle it returns HTTP 402 Payment Required with the reason — it does not raise:
reason string on a 402 is one of no_subscription (physical create with no active subscription), vehicle_limit_reached (paid plan at its ceiling), or subscription_inactive. Within-limit creates instead return the success codes with reason free_tier or allowed.
Free tier is exactly 1 SITL vehicle; a physical drone requires an active Stripe subscription, and the paid SITL ceiling is vehicle_count + 1. See Stripe Billing & Vehicle Limits.
type is physical | sitl; vehicle_type is copter | rover. A physical create allocates a drone IP and provisions a Janus room; a sitl create spawns a 3-container Docker stack and can block for up to ~80s. See SITL Drone Lifecycle.
Connection management
| Endpoint | Method | Purpose |
|---|---|---|
/drone/action/connection?id={drone_id} | GET | Open (or return) the pooled rosbridge connection; polls up to 5s for it to go live, then 200 {drone_id} or 400. |
/drone/action/connection?id={drone_id} | DELETE | Close the connection and drop it from the pool (200, or 500 on failure). |
connection explicitly before a command — any /drone/action/* call lazily obtains/reconnects a pooled connection via get_client.
Control actions
All control actions arePOST /drone/action/<name>, JWT Bearer, and take a JSON body whose only required field is drone_id. On success they return 200 with {"message": "successfully sent message"} (casing varies); on failure 500 with {"error": "..."}.
| Action | Extra body fields | MAVLink / MAVROS effect |
|---|---|---|
arm | — | COMMAND_LONG cmd 400 param1=1 (arm). Pre-pushes SITL video-room details. |
arm_drone | — | Duplicate of arm — same handler logic. |
disarm_drone | — | COMMAND_LONG cmd 400 param1=0 (disarm). |
takeoff | altitude | Set GUIDED → arm → CommandTOL takeoff. |
land | — | set_mode custom_mode LAND. |
rtl | — | set_mode custom_mode RTL. |
guided | — | set_mode custom_mode GUIDED. |
set_mode | mode_info | set_mode with mode_info as the raw custom_mode value. |
move | position_*, velocity_*, coordinate_frame, type_mask, yaw, yaw_rate | Publish PositionTarget to /mavros/setpoint_raw/local. |
goto_gps_location | latitude, longitude, altitude | Publish GlobalPositionTarget to /mavros/setpoint_raw/global. |
set_param | param_id, value | Set one ArduPilot param (with retry). |
set_params | params ({name: value}) | Batch param set. |
motortest | motor_id, percentage | COMMAND_LONG cmd 209 MAV_CMD_DO_MOTOR_TEST. |
push_mission | mission_id | mission/push; auto-prepends a TAKEOFF waypoint. |
push_geofence | geofence_id | geofence/push. |
clear_fence | — | Clear all fence items. |
sync_geofences | — | Replace fence with all the user’s enabled geofences. |
start_mission | takeoff_altitude? | Push mission → GUIDED → arm → GUIDED takeoff. |
video-source | source (CAMERA | TEST) | Publish source change to the on-drone video module. |
Arm / disarm (and the duplicate)
There are two functionally identical arm endpoints:POST /drone/action/arm (drone_routes.py:413) and POST /drone/action/arm_drone (drone_routes.py:1273). Both fetch the drone, and for SITL drones with a video_room_id they first push video-room details (status: START) so the core container has the room when it detects the ARMED state, then call drone_control_service.arm_drone. Disarm has only one endpoint, POST /drone/action/disarm_drone. All three ultimately send MAV_CMD_COMPONENT_ARM_DISARM (400) as a COMMAND_LONG.
Flight modes and takeoff
land, rtl, and guided are fixed-mode shortcuts; set_mode is the general form.
takeoff runs a three-step sequence — set GUIDED, arm, then CommandTOL to the requested altitude:
Guided movement — move and goto_gps_location
These are the two footgun-prone bodies where the Swagger docstring and the actual keys the service reads diverge.
Parameters
| Endpoint | Method | Purpose |
|---|---|---|
/drone/action/set_param | POST | Set one param: {drone_id, param_id, value}. |
/drone/action/set_params | POST | Set many: {drone_id, params: {NAME: value}}. |
/drone/{drone_id}/params?params=CSV | GET | Read params (all if params omitted) → {params: [{param_id, value}]}. |
/drone/{drone_id}/params/pull | POST | Force MAVROS to refresh its cache from the FCU → {success, param_received}. |
GET /drone/{id}/params uses the batch GetParameters service (~1s for all params vs. minutes of individual reads). params/pull invokes the MAVROS ParamPull service before reads if the cache may be stale.
Motor test
motortest sends MAV_CMD_DO_MOTOR_TEST (209) as a COMMAND_LONG with motor_id (1-based) and percentage throttle:
Missions and geofences
push_mission loads the mission’s stored MAVLink waypoints, auto-prepends a MAV_CMD_NAV_TAKEOFF (cmd 22) waypoint if the first stored command isn’t already a takeoff (drone_control_service.py:804), pushes via /mavros/mission/push, and fails loudly if wp_transfered doesn’t match the count sent. start_mission runs the full launch sequence (push mission → GUIDED → arm → GUIDED takeoff) and then relies on the frontend to switch to AUTO once altitude is reached — the server-side _monitor_takeoff_and_switch_to_auto helper exists but start_mission does not call it.
The older CLAUDE.md description of a server-generated reversed return path / RTL / speed waypoints is stale — the backend uploads the stored waypoints as-is plus the single TAKEOFF prepend. The waypoint payload format and how missions/geofences are persisted are covered in Missions & Geofences API and Mission & Geofence MAVLink Format.
push_geofence, clear_fence, and sync_geofences manage the on-FCU fence (sync_geofences replaces the fence with every enabled geofence for the user). These return {message, result} on 200 and {error} on 500.
Video source
POST /drone/action/video-source is the one action route using the newer {success, ...} envelope. It validates source against VIDEO_ROOM_SOURCES:
Physical-drone bootstrap (non-JWT)
These two endpoints let a physical drone provision and update itself. Neither uses a user JWT.GET /drone/activate — first boot
Authenticated by a 10-digit numeric
token header. The gateway waits up to ~15s for the drone’s WireGuard VPN to come up (status_drone_vpn), fetches ECR docker-login credentials, presigns the correct compose file from INSTALLER_BUCKET (docker-compose.yml when DEPLOYMENT_ENVIRONMENT starts with dev, else docker-compose.prod.yml), then clears the activation token. Returns {username, password, repository, vpn, compose}. Errors: 406 (bad/unknown token), 500 (VPN timeout or failure).GET /drone/pull — self-update
Authenticated purely by VPN source IP via
check_vpn_ip. The gateway resolves the drone with get_drone_by_ip(request.vpn_ip), reads the SSM parameter /{RESOURCE_TAG}/pull_role, STS assume_role for 1 hour, and returns temporary AWS credentials ({accessKeyId, secretAccessKey, sessionToken, accountId, region}) so the drone can pull its own images from ECR.Gotchas to preserve
Two arm endpoints, one disarm
Two arm endpoints, one disarm
/drone/action/arm and /drone/action/arm_drone are duplicates. Do not delete one without checking the Dashboard — both are live surface area. Disarm is only /drone/action/disarm_drone.Docstring bodies lie for set_mode / move / goto / video-source
Docstring bodies lie for set_mode / move / goto / video-source
The inline Swagger for
set_mode (mode_info object), move (x/y/z), goto_gps_location (lat/lng), and video-source (main/thermal) does not match the keys/values the code actually reads. Trust drone_control_service.py and drone_utils.py, not the docstrings.201 for reads, and mixed envelopes
201 for reads, and mixed envelopes
Drone CRUD returns
201 even for GET /drones, and most action routes return bare {message}/{error} bodies. Only video-source uses the {success, ...} envelope. The UI is coupled to these exact shapes and codes.start_mission depends on the frontend for AUTO
start_mission depends on the frontend for AUTO
start_mission only gets the vehicle airborne in GUIDED; the AUTO switch is a client responsibility. The unused _monitor_takeoff_and_switch_to_auto server helper is an alternate path, not the live one.Related pages
DroneControlService & Rosbridge Dispatch
Connection pooling, SITL IP translation, MAVLink command numbers, and telemetry emission behind every action.
Socket.IO Telemetry Streaming
Subscribe to live GPS, altitude, logs, and the combined dashboard stream after connecting.
Missions & Geofences API
Build and persist the missions and fences that
push_mission / push_geofence upload.SITL Drone Lifecycle
What a
type: sitl create actually spins up in Docker.Stripe Billing & Vehicle Limits
Why
POST /drone can return 402 and how vehicle quotas are enforced.VPN IP Authentication & Jumphost Routing
The
check_vpn_ip model behind /drone/pull and other drone callbacks.
