The seven flows at a glance
| Flow | Entry point | Transport into the drone | Auth at the edge |
|---|---|---|---|
| 1. User command path | POST /api/v1/drone/action/* | rosbridge WS :9090 | JWT Bearer |
| 2. Telemetry stream | Socket.IO subscribe_telemetry | rosbridge WS :9090 (subscribe) | JWT in ?token= |
| 3. Video pipeline | drone creation / video_room/* | rosbridge topics + WHIP + Janus | JWT + Janus room token |
| 4. SITL container spawn | POST /api/v1/drone {type: sitl} | Docker API → new container | JWT + Stripe entitlement |
| 5. Mission upload + execute | POST /drone/action/push_mission then start_mission | rosbridge mission/push service | JWT Bearer |
| 6. Manual gamepad control | WS {ws_proxy}/redispad/{droneId} | Redis pub/sub → drone | JWT query param |
| 7. Activation + self-update | GET /drone/activate, GET /drone/pull | HTTP → ECR/S3/STS | 10-digit token / VPN source IP |
: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.Dashboard issues the request
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.Ingress through the single jumphost
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.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.Pooled rosbridge connection
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).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.Drone executes, response bubbles back
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.2. Telemetry stream (Drone → Gateway → UI)
Live drone data (GPS, altitude, logs, diagnostics, heading) pushed to the browser over Socket.IO.Authenticated Socket.IO handshake
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).Subscribe to the dashboard stream
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.Throttled subscribe frames to the drone
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).Inbound publish → callback → emit
Connection.on_message buffers the latest per topic into queues[topic] and invokes socketio_callback → DroneControlService._emit_telemetry.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.UI routes by type
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.3. Video pipeline (Core/SITL → WHIP → Janus → UI)
Live H264 video flows out-of-band from the Gateway’s rosbridge control plane.Gateway creates the Janus room
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.Room details pushed to the drone
/video_room_details and toggles streaming with /video_room_state (START/STOP). See Janus Video Rooms & On-Drone Video Control.Drone builds a GStreamer → WHIP pipeline
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.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.Dashboard subscribes over WebRTC
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).{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.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.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>).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}.Start the 3-container stack in order
_wait_for_rosbridge). Any failure triggers reverse-order cleanup (core → gamepad → SITL).Persist and wire video
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.Identical to a physical drone thereafter
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.Build and persist the mission
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.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.Verify the transfer
/mavros/mission/push service (timeout ROSBRIDGE_SERVICE_TIMEOUT, 30s), verifies wp_transfered == len(mavlink_waypoints), and records drone.mission_id.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).6. Manual gamepad control (UI → WS Proxy → Redis → Drone)
A low-latency path entirely separate from the Gateway, for direct piloting.Sample the controller
GamepadService samples physical gamepad / keyboard / virtual joystick at 60Hz into AppStateService.controllerData$. See Vehicle Commands & Gamepad (redispad).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.Proxy resolves the drone and publishes to Redis
{drone_ip}:gamepad_input. See Redis Message Bus & WebSocket Interfaces.On-drone routing
RedisHandler receives the message and MessageRouter routes it: axis frames → GuidedVelocityController, which sends SET_POSITION_TARGET_LOCAL_NED body-frame velocity; button frames → mode/gimbal/camera commands.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.Activation with a 10-digit token
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).Return ECR creds + presigned compose + VPN config
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.Join the drone WireGuard 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.Self-update authenticated by VPN source IP
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.In-flight callbacks also trust the VPN IP
get_drone_by_ip(request.vpn_ip). See Executions, Assets & Reports.Where each flow enters and exits
: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.
