src/routes/execution_routes.py and src/routes/asset_routes.py — and both deliberately split their endpoints into two auth zones:
- Drone / gamepad callbacks (
@check_vpn_ip) — the on-drone uploader and gamepad service call these over the WireGuard plane. There is no JWT; the caller is identified purely by its source IP. - UI endpoints (
@jwt_required()) — the Dashboard reads flight history, triggers analysis, downloads archives, and manages report recipients with a Bearer JWT.
For the auth models in general (JWT Bearer vs VPN-source-IP vs token/signature) and the response-envelope conventions, see HTTP API Overview & Auth Models. The IP-trust mechanics behind
@check_vpn_ip live in VPN IP Authentication & Jumphost Routing. The service-layer internals (log parsing, ZIP building, SMTP) are on Executions, Log Analysis & Reports and S3 Assets, HLS Video & Execution Archives.The flight-data lifecycle
A single flight moves through both auth zones. The drone side (green) writes data during and after the flight; the UI side (blue) reads it back.Drone callbacks (VPN-IP authenticated)
Every handler in this group resolves the calling drone the same way —generic_drone_service.get_drone_by_ip(request.vpn_ip) — where request.vpn_ip is set by the check_vpn_ip decorator from the 10.71.x source IP (or an X-Drone-IP: SKYHUB_SITL_* header for SITL). If no drone matches, the callback returns 404 “Drone not found”.
Execution endpoints
| Method & path | Auth | Purpose |
|---|---|---|
POST /api/v1/executions/start | check_vpn_ip | Begin tracking a flight (called on ARM) |
POST /api/v1/executions/<id>/complete | check_vpn_ip | Finish a flight (on land/disarm); enqueues or defers the report |
POST /api/v1/executions/<id>/log | check_vpn_ip | Attach the uploaded .bin DataFlash log; triggers async analysis |
GET /api/v1/executions/current | check_vpn_ip | The in-progress execution for the calling drone (or null) |
POST /executions/start
POST /executions/start
Starts a
MissionExecution in status in_progress. Body is optional; if mission_id is omitted it falls back to the drone’s currently loaded drone.mission_id (execution_routes.py:172-187).POST /executions/{execution_id}/complete
POST /executions/{execution_id}/complete
Marks the execution finished and computes
duration_seconds. status must be one of completed | aborted | error (default completed); an unknown value returns 400. Ownership is re-verified against the calling drone: the execution must belong to drone.user_id (404 otherwise) and to drone.id (403 otherwise) — see execution_routes.py:237-242.After completion it branches on pending uploads (get_pending_asset_count):- 0 pending →
report_service.enqueue_archive_and_report(execution_id, app)runs the archive + report on a background thread pool. - >0 pending →
mark_report_pending(execution_id); the report is deferred until the lastcomplete_uploadlands (see Deferred reports).
POST /executions/{execution_id}/log
POST /executions/{execution_id}/log
Links an already-uploaded log asset (
asset_id) to the execution and kicks off background pymavlink analysis. It verifies both the execution and the asset belong to the calling drone/user before linking (execution_routes.py:311-321). Analysis only runs if the asset has an s3_key; otherwise it logs a warning and skips.GET /executions/current
GET /executions/current
Returns the drone’s current in-progress execution via
get_in_progress_execution(user_id, drone_id), or {"success": true, "data": null} when idle. The on-drone uploader uses this to discover which execution_id to tag its assets with.Asset upload callbacks
The uploader performs a two-call handshake per file:authenticate_upload mints a presigned S3 PUT URL and (usually) a DB row, the drone uploads directly to S3, then complete_upload flips the asset to ready.
| Method & path | Auth | Purpose |
|---|---|---|
POST /api/v1/authenticate_upload | check_vpn_ip | Presigned S3 upload URL + create Asset (status pending) |
POST /api/v1/complete_upload | check_vpn_ip | Mark the asset ready; may dispatch a deferred report |
asset_routes.py returns the older raw-JSON envelope here ({"success": true, "url": ..., "asset_id": ...}), not the {success, data} wrapper used by execution routes. It also imports the response helpers from utils.common_helper rather than src.utils.common_helper — both module paths resolve to the same file, so keep both importable.authenticate_upload
Body requires The presigned URL expiry is 3 hours (
file_name; asset_type is one of video | image | logs. Pass execution_id to link the asset to a flight (this is what later triggers the deferred report). For non-video assets a checksum is required and is de-duplicated: an already-ready asset with the same checksum returns 409.AssetService.expiration), and the S3 key scheme is drone/{drone_id}/{asset_type}/{file_name} (asset_service.py:169).Deferred report logic
Because assets keep uploading after landing, the report cannot always be sent whencomplete fires. The gateway resolves this race with a two-trigger design driven by the MissionExecution.report_pending flag and the count of pending-status assets.
Trigger points, both in code:
complete_execution(execution_routes.py:255-267) — enqueues immediately if nothing is pending, otherwise marks the report pending._dispatch_pending_report_if_ready(asset_routes.py:16-51) — runs after everycomplete_upload; when the execution isreport_pendingandget_pending_asset_count == 0, it callsreport_service.send_report(execution_id)thenclear_report_pending(execution_id).
Async pymavlink log analysis
Attaching a log runsLogAnalysisService.analyze_log_from_s3(s3_key), which downloads the ArduPilot .bin DataFlash log and parses it with pymavlink into a FlightStats dataclass (flight time, distance, altitude/speed, battery, GPS quality, vibration/clipping, mode changes, error subsystems). Results are written to the MissionExecution row via _persist_flight_stats → execution_service.update_flight_stats(...) (~40 stat columns).
There are two entry points with different execution models:
| Endpoint | Auth | Model | Notes |
|---|---|---|---|
POST /executions/<id>/log → _analyze_log_async | check_vpn_ip | Background (max_workers=4 pool) | Fire-and-forget after linking the log |
POST /executions/<id>/analyze-log | jwt_required | Synchronous | UI waits for the result and gets the stats back |
Both paths explicitly catch
SystemExit — pymavlink calls sys.exit() on corrupted logs, which would otherwise kill the worker (execution_routes.py:675, :744). Preserve those guards. The synchronous analyze-log returns stats.to_dict(); if the execution has no linked log asset it returns 400 “No log asset linked to this execution”.UI endpoints (JWT authenticated)
The Dashboard reads flight history and manages reports with a Bearer JWT. Every handler verifies ownership through theuser_id from get_jwt_identity().
Executions
| Method & path | Purpose |
|---|---|
GET /api/v1/executions?limit&offset | All executions for the current user (pagination clamped to 1–100) |
GET /api/v1/executions/vehicle/<drone_id>?limit&offset | Executions for one drone (403 if not owned) |
GET /api/v1/executions/<id> | Detail with enriched assets, counts, drone/mission names, and the log asset |
DELETE /api/v1/executions/<id> | Delete the execution, its archive, and all linked assets (S3 + DB) |
Returns
execution.to_dict() merged with drone_name, mission_name, asset_counts, assets (ready, non-log, each enriched with a presigned URL), and log_asset (returned separately, not in the assets array). Only ready assets are counted (execution_service.py:97-145).Reports, archives & flight stats
| Method & path | Purpose |
|---|---|
POST /api/v1/executions/<id>/analyze-log | Synchronous re-analysis; returns full FlightStats |
GET /api/v1/executions/<id>/flight-stats | Stored stats from the last analysis (no re-parse) |
GET /api/v1/executions/<id>/archive | Presigned ZIP URL; generates the archive if missing |
POST /api/v1/executions/<id>/archive | Force-regenerate the ZIP archive |
POST /api/v1/executions/<id>/send-report | Email the report to a specific address |
GET /api/v1/user/report-emails | Current report recipients |
PUT /api/v1/user/report-emails | Update the extra recipient list |
GET / POST /executions/{id}/archive
GET / POST /executions/{id}/archive
Both delegate to
archive_service.get_archive_download_url(execution_id, regenerate=…). The archive is a ZIP of the execution’s ready assets stored at drone/{drone_id}/archives/*.zip. The presigned download URL and the archive itself are valid for 7 days; the service auto-regenerates when the archive is missing or older than 7 days.GET /executions/{id}/flight-stats
GET /executions/{id}/flight-stats
Reads the persisted columns directly — it does not re-run pymavlink. Returns
flight_time_seconds, total_distance_meters, max_altitude_meters, avg_speed_ms, max_speed_ms, log_analyzed_at, and has_log_asset (execution_routes.py:983-993). If log_analyzed_at is null the log has not been analyzed yet.POST /executions/{id}/send-report
POST /executions/{id}/send-report
Body is
{ "email": "<address>" }, validated against EMAIL_REGEX. Sends the report to that single address via report_service.send_report_to_email(...) (independent of the user’s configured recipient list). The address is masked in logs (_mask_email). Returns 400 on invalid format, 404 if the execution is not owned, 500 if the send fails.GET / PUT /user/report-emails
GET / PUT /user/report-emails
GET returns { email, extra_emails (list), all_recipients }. PUT accepts { "extra_emails": "[email protected],[email protected]" } (comma-separated); each address is validated with EMAIL_REGEX, normalized, and stored on user.report_extra_emails (set to null when empty). all_recipients combines the account email with the extras.Asset read & download (JWT)
The same asset rows written by the drone callbacks are read back by the UI. These are the HTTP endpoints; the S3 key scheme, HLS rewriting, thumbnails, and two-phase delete are documented in S3 Assets, HLS Video & Execution Archives.| Method & path | Purpose |
|---|---|
GET /api/v1/assets/user | Per-vehicle asset counts for the user |
GET /api/v1/assets/<vehicle_id> | All assets for a vehicle (non-video get presigned download URLs) |
GET /api/v1/assets/video/<vehicle_id> | Video assets for a vehicle (404 if none) |
GET /api/v1/assets/<vehicle_id>/video/<asset_id> | Rewritten HLS .m3u8 playlist (application/vnd.apple.mpegurl) |
GET /api/v1/assets/<vehicle_id>/download/<asset_id> | Presigned download URL for any single asset |
POST /api/v1/assets/<vehicle_id>/delete | Delete assets by ids list (S3 + DB); 400 on empty list |
GET /assets/<vehicle_id>/video/<asset_id> returns the raw M3U8 body (not the {success, data} envelope) with mimetype="application/vnd.apple.mpegurl", because file:// segment lines are rewritten server-side into presigned .ts URLs so a browser HLS player can fetch them (asset_routes.py:361-397).Where this connects
Service internals
ExecutionService, LogAnalysisService (pymavlink), and ReportService (SMTP + thread pools).Assets & archives
S3 key layout, HLS m3u8 rewriting, thumbnails, and the two-phase delete.
VPN IP auth
How
check_vpn_ip trusts 10.71.x source IPs and X-Drone-IP for SITL.Drone actions
The control commands (arm, takeoff, missions) that bracket an execution.
On-drone uploader
The SkyCore side that calls these callbacks over the VPN.
Cross-system flows
The end-to-end execution + report path across repositories.

