SkyHub is not one program — it is a Dashboard, a Gateway, on-drone SkyCore OS, a satellite ecosystem (Janus, WHIP, WS Proxy, User VPN, SITL), and AWS infrastructure, glued together by a handful of repeating end-to-end sequences. This page is the canonical reference for those sequences: exactly where a message enters and exits each system, which transport and auth model carries it, and the gotchas a future editor must preserve. For the static picture (what each box is) see Platform Architecture Overview. For the transports themselves see Real-time Transport Channels, for the three auth models see Authentication & Security Model, and for the WireGuard/jumphost wiring see Network & VPN Topology. This page assumes those and focuses purely on the flows.

The seven flows at a glance

FlowEntry pointTransport into the droneAuth at the edge
1. User command pathPOST /api/v1/drone/action/*rosbridge WS :9090JWT Bearer
2. Telemetry streamSocket.IO subscribe_telemetryrosbridge WS :9090 (subscribe)JWT in ?token=
3. Video pipelinedrone creation / video_room/*rosbridge topics + WHIP + JanusJWT + Janus room token
4. SITL container spawnPOST /api/v1/drone {type: sitl}Docker API → new containerJWT + Stripe entitlement
5. Mission upload + executePOST /drone/action/push_mission then start_missionrosbridge mission/push serviceJWT Bearer
6. Manual gamepad controlWS {ws_proxy}/redispad/{droneId}Redis pub/sub → droneJWT query param
7. Activation + self-updateGET /drone/activate, GET /drone/pullHTTP → ECR/S3/STS10-digit token / VPN source IP
Two independent control planes reach a drone: the Gateway’s rosbridge path (:9090, telemetry + mission/flight commands) and the WS Proxy gamepad path (:7070 → Redis → drone :5001). They never touch each other — do not assume manual control flows through the Gateway.

1. User command path (UI → Gateway → Drone)

A discrete control action (arm, takeoff, set_mode, goto, motortest, …) issued from the Dashboard.
1

Dashboard issues the request

The Angular AuthInterceptor attaches the Bearer JWT and the UI sends POST /api/v1/drone/action/{arm|takeoff|set_mode|move|goto_gps_location|...} with a JSON body such as {"drone_id": 42, "altitude": 5}. See Drone Management & Control Actions.
2

Ingress through the single jumphost

In production the request hits nginx on the WireGuard EC2 host (prod.skyhub.ai:5000), which TLS-terminates and proxies to gateway.skyhub-prod.internal:5000 inside the private subnet. There is no ALB — this one instance is the sole ingress. See VPC, WireGuard Jumphost & nginx Routing.
3

Gateway validates and delegates

flask-jwt-extended validates the JWT, then the thin route delegates to a DroneControlService.<cmd> method in src/service/drone_control_service.py.
4

Pooled rosbridge connection

The command calls get_client(user_id, drone_id) (src/service/drone_control_service.py:518), which acquires connection_lock (5s) and returns the pooled Connection — or, on a cache miss, checks ownership via drone_service.get_drone_by_id_and_user and resolves the target IP: physical drones use drone.ip; SITL drones translate to SITL_HOST when REMOTE_DOCKER_ENABLED else host.docker.internal (_get_client, src/service/drone_control_service.py:532).
5

Frame is queued and sent

request_format builds a rosbridge call_service (or publish) frame; send_message enqueues it on the bounded outgoing queue, and the Connection consumer thread sends it over the WebSocket — directly, or through JUMPHOST_IP:9090 using x-drone-ip/x-drone-port headers. See Rosbridge Connection & Reconnect.
6

Drone executes, response bubbles back

On the drone, mavp2p (the single MAVLink router, see MAVLink Routing) forwards the resulting COMMAND_LONG/SET_MODE to the ArduPilot FCU. The service response returns as an op=service_response frame, resolved via a threading.Event in send_service_call_with_response (src/rosbridge/connection.py:236), and the route returns 200.
Ownership is enforced only at connection creation, not per message. Once a Connection is pooled for a drone_id, _get_client returns it without re-checking ownership, and telemetry rooms are drone-scoped (drone_{id}_{stream}), not user-scoped. Nearly all call_service frames also use the hardcoded rosbridge id "1", so concurrent service calls on the same connection can collide. Preserve these constraints — details in DroneControlService & Rosbridge Dispatch.

2. Telemetry stream (Drone → Gateway → UI)

Live drone data (GPS, altitude, logs, diagnostics, heading) pushed to the browser over Socket.IO.
1

Authenticated Socket.IO handshake

The Dashboard WebsocketService opens a Socket.IO connection to the Gateway root namespace with ?token=<JWT> (base URL = environment.url minus /api/v1). handle_connect decodes the HS256 token and stores user_id in the Flask session (src/routes/socket_routes.py:31).
2

Subscribe to the dashboard stream

The UI emits subscribe_telemetry {drone_id, stream_type: 'dashboard'}. The handler joins room drone_{drone_id}_dashboard (src/routes/socket_routes.py:123) and _subscribe_dashboard (src/routes/socket_routes.py:211) fans out rosbridge subscriptions: GPS, GPS_RAW, rel_alt, /rosout logs, VFR_HUD, IMU, home position, and /diagnostics.
3

Throttled subscribe frames to the drone

Each start_*_data call routes through get_client to the pooled Connection, which sends subscribe frames throttled to TELEMETRY_THROTTLE_RATE (~200ms / 5Hz); /diagnostics is subscribed unthrottled (throttle_rate=0).
4

Inbound publish → callback → emit

The drone’s MAVROS publishes topic messages; Connection.on_message buffers the latest per topic into queues[topic] and invokes socketio_callbackDroneControlService._emit_telemetry.
5

Server-side yaw enrichment

_emit_dashboard_data (src/service/drone_control_service.py:1159) injects a server-computed yaw into GPS payloads — VFR_HUD heading first, else IMU quaternion → compass heading via (90 - yaw) % 360 — and emits telemetry_data to room drone_{id}_dashboard.
6

UI routes by type

The Dashboard routes telemetry_data by data.type into per-stream RxJS Subjects; the map marker animates from GPS+yaw. On reconnect, activeSubscriptions are replayed. See Real-time Telemetry Client.
Only the dashboard stream delivers data. The single-stream rooms (gps/logs/relalt/system) are effectively dead code: every mapped topic is also a dashboard topic, so _emit_telemetry always routes to _emit_dashboard_data and never to _emit_to_stream_subscribers. A client that subscribes to a single stream joins its room but receives nothing. Also note the room-naming security gotcha: rooms are drone-scoped and subscribe_telemetry performs no ownership check. See Socket.IO Telemetry Streaming.

3. Video pipeline (Core/SITL → WHIP → Janus → UI)

Live H264 video flows out-of-band from the Gateway’s rosbridge control plane.
1

Gateway creates the Janus room

On drone creation (or video start), VideoService.create connects to JANUS_URL and creates a Janus VideoRoom whose id == drone.id with videocodec: h264 (src/service/video_service.py:19), generating room_id/password/token.
2

Room details pushed to the drone

The Gateway publishes the room details over the rosbridge topic /video_room_details and toggles streaming with /video_room_state (START/STOP). See Janus Video Rooms & On-Drone Video Control.
3

Drone builds a GStreamer → WHIP pipeline

The on-drone core video_stream module (or the SITL video_stream_node) selects a camera via CameraFactory, builds a GStreamer pipeline with HW nvv4l2 encode, and whipsinks the H264 stream to the WHIP server at SKYHUB_SERVER_URL/whip/endpoint/<id>. See Video Streaming (RTSP → WHIP/WebRTC) and WHIP Ingest Server.
4

WHIP registers a Janus publisher

simple-whip-server maps the ingest to a Janus VideoRoom publisher over the Janus WebSocket API (ws://janus:8188). See Janus WebRTC SFU.
5

Dashboard subscribes over WebRTC

The Dashboard JanusService connects to janusGatewayUrl (:8188 via the jumphost), joins the same room with the token, and attaches remote WebRTC tracks to the <video> element; UDP media relays through the jumphost RTP range. See App State & Video (Janus/WebRTC).
ArUco precision-landing tracking is not burned into the video. It is published to Redis {ip}:aruco_tracking and rendered as a frontend canvas overlay on top of the WebRTC feed. Recorded clips are separately segmented MP4→HLS and served from S3 (see S3 Assets, HLS Video & Execution Archives).

4. SITL container spawn (UI → Gateway → Docker)

Creating a simulated drone spins up a 3-container stack on a remote Docker host and then treats it exactly like a physical drone.
1

Entitlement check

POST /api/v1/drone {type: sitl, vehicle_type} runs SubscriptionService.can_user_add_vehicle — the free tier allows 1 SITL, more requires an active Stripe subscription. See Stripe Billing & Vehicle Limits.
2

Lazy service resolution

get_service('sitl') builds SITLDroneService only if ENABLE_SITL; it picks a Docker client (local docker.from_env or a remote DockerClient over the jumphost tunnel with ECR auth). SITL creation is blocked outright on server environments — production spawns run on the on-prem office host (nexus0 @ <office-docker-host>).
3

Allocate a container number

save() (src/service/sitl_drone_service.py:599) enforces USER_SITL_MAX_COUNT, finds a free container number (1..101), and ensures a shared Redis container. Ports derive arithmetically: rosbridge is 9090 + n; container names use the prefix SKYHUB_SITL_{n}_{name}.
4

Start the 3-container stack in order

Startup order is SITL (ArduPilot) → gamepad → core (MAVROS + rosbridge, blocking on _wait_for_rosbridge). Any failure triggers reverse-order cleanup (core → gamepad → SITL).
5

Persist and wire video

The Drone row is saved with ip == container_name (uniqueness) and port = 9090 + n; a Janus video room is created and video-room details pushed with status START. Demo assets and default ArduPilot params (fence/battery/logging) are applied.
6

Identical to a physical drone thereafter

The Gateway then connects rosbridge to DOCKER_HOST_IP:port and treats the container identically to a physical drone — the same rosbridge/topic contract makes SITL and physical interchangeable. See SITL Drone Lifecycle and SITL Simulator.

5. Mission upload + execute (UI → Gateway → Drone)

Two distinct operations: uploading waypoints, then starting execution.
1

Build and persist the mission

The operator builds a mission in the Dashboard edit-mission sidebar; MissionService persists MAVLink-format waypoints (frame/command 16/22/21/20/178) via /mission and /mission/{id}/points. See Missions & Geofences API and Mission & Geofence MAVLink Format.
2

Push mission to the FCU

POST /api/v1/drone/action/push_mission {drone_id, mission_id} calls DroneControlService.push_mission (src/service/drone_control_service.py:784), which loads MissionPoints ordered by sequence, converts each via to_mavlink_waypoint (lat→x_lat, lng→y_long, altitude→z_alt; src/models/mission_point.py:59), and auto-prepends a synthetic TAKEOFF (cmd 22) if the first command is not already 22.
3

Verify the transfer

It calls the rosbridge /mavros/mission/push service (timeout ROSBRIDGE_SERVICE_TIMEOUT, 30s), verifies wp_transfered == len(mavlink_waypoints), and records drone.mission_id.
4

Start the mission

POST /drone/action/start_mission {takeoff_altitude?} runs start_mission (src/service/drone_control_service.py:615): it re-uploads the mission, then sets GUIDED mode, arms, and issues a GUIDED takeoff to takeoff_altitude (defaulting to the first waypoint altitude).
5

Frontend switches to AUTO

The frontend monitors relative altitude and switches the vehicle to AUTO to fly the waypoints. A server-side helper _monitor_takeoff_and_switch_to_auto (src/service/drone_control_service.py:676) exists but is not invoked by start_mission.
The CLAUDE.md description of a reversed return path / RTL / speed generation is stale. The backend uploads the stored waypoints as-is plus the single synthetic TAKEOFF prepend — there is no auto-generated return path or RTL command in push_mission. Do not reintroduce that behavior on the assumption it exists.

6. Manual gamepad control (UI → WS Proxy → Redis → Drone)

A low-latency path entirely separate from the Gateway, for direct piloting.
1

Sample the controller

The Dashboard GamepadService samples physical gamepad / keyboard / virtual joystick at 60Hz into AppStateService.controllerData$. See Vehicle Commands & Gamepad (redispad).
2

Open the redispad WebSocket

ControllerDataSenderService forwards frames to VehicleCommandService, which opens a raw WebSocket to the WS Proxy at {ws_proxy}/redispad/{droneId}?access_token=<JWT> — a channel completely separate from the Gateway’s rosbridge path. See WebSocket Gamepad Proxy.
3

Proxy resolves the drone and publishes to Redis

The WS Proxy resolves the drone IP from PostgreSQL and publishes commands to Redis {drone_ip}:gamepad_input. See Redis Message Bus & WebSocket Interfaces.
4

On-drone routing

The drone gamepad service RedisHandler receives the message and MessageRouter routes it: axis framesGuidedVelocityController, which sends SET_POSITION_TARGET_LOCAL_NED body-frame velocity; button frames → mode/gimbal/camera commands.
5

Status returns via Redis

Status flows back via Redis {ip}:output / {ip}:aruco_tracking → WS Proxy → UI.
Axes are silently dropped unless guided control is explicitly enabled AND the vehicle is in GUIDED, with a dead-man 0.5s timeout. Movement uses GUIDED velocity setpoints (zero = hold position), deliberately replacing the dangerous RC_CHANNELS_OVERRIDE approach. See Guided Velocity Control & Safety Model.

7. Physical drone activation + self-update (Drone → Gateway → AWS)

How a physical drone bootstraps its software and later pulls updates — the two flows use different auth models.
1

Activation with a 10-digit token

A booting drone calls GET /api/v1/drone/activate with a 10-digit token header (src/routes/drone_routes.py:1527). The Gateway waits up to ~15s for the WireGuard VPN to come up (status_drone_vpn).
2

Return ECR creds + presigned compose + VPN config

The Gateway calls ECR get_authorization_token and generates an S3 presigned URL (300s) for the appropriate docker-compose file (docker-compose.yml when DEPLOYMENT_ENVIRONMENT starts with dev, else docker-compose.prod.yml) from INSTALLER_BUCKET, returning {username, password, repository: DOCKER_REPO, vpn, compose}. It then clears the activation token.
3

Join the drone WireGuard plane

The drone joins the drone plane (10.71.0.0/16) managed by the User VPN service, which writes iptables rules from user_drone_access so only owning users can reach it. See User VPN & Network Isolation.
4

Self-update authenticated by VPN source IP

For updates, the drone calls GET /api/v1/drone/pull authenticated purely by its 10.71.x VPN source IP (check_vpn_ip middleware; src/routes/drone_routes.py:1638). The Gateway reads SSM /{RESOURCE_TAG}/pull_role, calls STS assume_role, and returns temporary 1-hour AWS credentials so the drone can pull its own core images from ECR.
5

In-flight callbacks also trust the VPN IP

During flight, drone/gamepad callbacks (executions start/complete/log, asset upload) also authenticate by VPN source IP; the Gateway resolves the drone via get_drone_by_ip(request.vpn_ip). See Executions, Assets & Reports.
check_vpn_ip is network-trust authentication, not cryptographic identity: any request whose source or X-Real-IP/X-Forwarded-For starts with 10.71. is trusted as that drone, and X-Drone-IP with a SKYHUB_SITL_ prefix is trusted whenever ENABLE_SITL. This is only safe because these endpoints sit behind the VPN and jumphost. See VPN IP Authentication & Jumphost Routing and Authentication & Security Model.

Where each flow enters and exits

Almost every external path funnels through the single WireGuard/nginx jumphost EC2 instance — API/Socket.IO (:5000), gamepad (:7070), Janus (:8188), Docker API (:2375), rosbridge (:9090, header-routed), and OTLP (:4317). That instance is both the architectural keystone and the primary single point of failure. When triaging a broken flow, confirm the jumphost and the relevant Cloud Map service (gateway/ws_proxy/janus/whip/redis/database.skyhub-prod.internal) are healthy before digging into application code. See Network & VPN Topology.