This page documents the endpoints that record a flight from arm to report: mission executions, the asset upload callbacks that attach media and logs to a flight, and the archive / report endpoints the UI uses afterward. Two blueprints back this surface — src/routes/execution_routes.py and src/routes/asset_routes.py — and both deliberately split their endpoints into two auth zones:
  • Drone / agent callbacks (@check_vpn_ip) — the on-drone uploader and agent 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”.
These endpoints carry no JWT and no per-request ownership token — anything that can present a 10.71.x source IP (or set X-Drone-IP behind the proxy when ENABLE_SITL) is trusted as that drone. Do not expose them outside the VPN plane. See src/middleware/drone_vpn.py.

Execution endpoints

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).
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 last complete_upload lands (see Deferred reports).
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.
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.
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.
1

authenticate_upload

Body requires 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.
The presigned URL expiry is 3 hours (AssetService.expiration), and the S3 key scheme is drone/{drone_id}/{asset_type}/{file_name} (asset_service.py:169).
Video segments skip the DB. When mime_type == "video/mp2t" (HLS .ts segments), request_upload returns asset_id = None and creates no Asset row — only the .m3u8 manifest gets an Asset. Callbacks that key off asset_id must tolerate null for segments.
2

complete_upload

Body is { "asset_id": <int> }. The service sets the asset to ready, stamps modified, and generates a thumbnail for images. If the asset carries an execution_id, it then calls _dispatch_pending_report_if_ready(...) which may fire the deferred report.

Deferred report logic

Because assets keep uploading after landing, the report cannot always be sent when complete 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 every complete_upload; when the execution is report_pending and get_pending_asset_count == 0, it calls report_service.send_report(execution_id) then clear_report_pending(execution_id).
Debugging stuck pending executions. A report can stay pending forever if:
  1. The last asset’s complete_upload never arrives (upload failed after authenticate_upload created the pending row), so pending_count never reaches 0.
  2. An asset is uploaded without execution_id — complete_upload only calls the dispatcher when asset.execution_id is set, so it never re-checks that execution.
  3. The immediate path (enqueue_archive_and_report) uses a background thread pool, but the deferred path (send_report) runs synchronously inside the complete_upload request with generate_archive=True, so a slow archive build stalls that HTTP call. This asymmetry is intentional — preserve it or unify both on the pool.
The two paths also use different thread pools: log analysis uses ThreadPoolExecutor(max_workers=4) in execution_routes.py:32; archive+report uses ThreadPoolExecutor(max_workers=2, thread_name_prefix="archive_gen") in report_service.py:18.

Attaching a log runs LogAnalysisService.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:
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 the user_id from get_jwt_identity().

Executions

object
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).
DELETE /executions/<id> is atomic-ish: it deletes the archive (best-effort), then all execution assets via asset_service.delete_asset(...), and only deletes the execution row if asset deletion succeeds. If S3 deletion raises, it rolls back and returns 500 to avoid orphaned S3 objects (execution_routes.py:531-547).

Reports, archives & flight stats

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.
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.
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 returns { email, extra_emails (list), all_recipients }. PUT accepts { "extra_emails": "a@x.com,b@y.com" } (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.
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.