skyhub_ws_proxy) is a tiny, single-file FastAPI service that relays manual
gamepad/joystick input from the browser Dashboard to a drone or ground vehicle. It exists to give
manual control its own low-latency, out-of-band path that is completely independent of the
Gateway’s rosbridge command channel — an operator’s stick movements never touch the Flask control
plane described in DroneControlService & Rosbridge Dispatch.
The whole service is ~190 lines in skyhub_ws_proxy/main.py. It does three things: resolve a drone’s
IP from PostgreSQL, relay a WebSocket to that drone (two ways), and expose one REST lookup.
This is the server end of the manual-control story. The browser side (60 Hz sampling, virtual joystick,
dead-man handling) lives in Vehicle Commands & Gamepad (redispad); what
the drone does with the frames (GUIDED velocity setpoints, safety gating) lives in
Guided Velocity Control & Safety Model.
Where it sits
There are two parallel control paths to every drone. Do not conflate them:- Rosbridge path (Gateway → drone
:9090): arm, takeoff, set_mode, missions, telemetry. - Gamepad path (WS Proxy → drone
:5001or Redis): raw manual stick input, 60 Hz.
/redispad mode (Redis pub/sub) is the preferred, production path; the /gamepad direct-bridge
mode is legacy. The Dashboard’s environment.ws_proxy points at wss://prod.skyhub.ai:7070 and always
uses /redispad (skyhub_dashboard/src/app/services/vehicle-command.service.ts:63).
The two relay modes
- /redispad (preferred)
- /gamepad (legacy direct)
websocket_redis_proxy — main.py:141. Decouples the client from the drone entirely: neither side holds
a socket to the other, so a drone restart or momentary VPN drop doesn’t tear down the browser connection.- Accept the client WebSocket, resolve
drone_ipfrom PostgreSQL. - Subscribe a Redis pub/sub to
{drone_ip}:outputand{drone_ip}:aruco_tracking. - Client → drone: every text frame received from the browser is published to
{drone_ip}:gamepad_input. - Drone → client: every message on the subscribed channels is decoded (UTF-8) and forwarded to the browser.
asyncio.gather(...); on disconnect the pub/sub is
unsubscribed and closed in a finally block.skyhub_ws_proxy/main.py
Drone IP lookup
Both relay modes and the REST endpoint resolve the target drone the same way — a single parameterized query against the shareddrone table (main.py:61):
asyncpg pool (min 2 / max 10) built at startup against
DB_HOST:5432 / DB_NAME. get_drone_ip_by_id raises ValueError("Drone not found") (or
"Drone IP not found") if the row or ip column is missing, and the WebSocket handler then sends the
error text to the client and closes. The ip stored here is the drone’s WireGuard 10.71.x address
for physical drones, or the SITL container name/IP for simulated ones — see
Database Schema Overview and User VPN & Network Isolation.
The
drone table is shared infrastructure, written by the Gateway and read by the WS Proxy and the
User VPN service. A schema change to drone.id, drone.user_id, or drone.ip ripples across all three.The Redis channel contract
In/redispad mode the proxy is just a translator between one browser WebSocket and three per-drone
Redis channels. The drone-side gamepad service (RedisHandler + MessageRouter) is the other end —
see Redis Message Bus & WebSocket Interfaces and
Redis Channels & MAVLink Port Map.
| Channel | Direction | Written by | Read by | Payload |
|---|---|---|---|---|
{drone_ip}:gamepad_input | client → drone | WS Proxy (publish) | drone gamepad service | Gamepad/joystick frames (axes + buttons) as text |
{drone_ip}:output | drone → client | drone gamepad service | WS Proxy (subscribe) | Status/ack messages routed back to the UI |
{drone_ip}:aruco_tracking | drone → client | drone precision-landing module | WS Proxy (subscribe) | ArUco marker tracking data for the frontend canvas overlay |
{ip}:camera_status — that status channel is consumed elsewhere
on the drone-OS side; the proxy only bridges output and aruco_tracking back to the browser.
REST endpoint
Look up a drone by id. Returns
{"drone_id", "user_id", "ip"} on success (200), {"error": "Drone not found"}
with 404 if the row is missing, or a 500 with the error string on failure. main.py:127.Client connection
The Dashboard’sVehicleCommandService opens the socket like this
(skyhub_dashboard/src/app/services/vehicle-command.service.ts:63):
environment.ws_proxy is wss://prod.skyhub.ai:7070, so a real URL looks like
wss://prod.skyhub.ai:7070/redispad/7?access_token=<jwt>. The {drone_id:int} path converter means a
non-integer id (e.g. /redispad/abc) 404s before any handler runs.
Configuration
All configuration is environment variables read at import time inmain.py:12-17. The defaults target the
production Cloud Map service names.
| Variable | Default | Purpose |
|---|---|---|
REDIS_HOST | redis.skyhub-prod.internal | Redis host for pub/sub (port 6379, db 0, hardcoded) |
REDIS_PASSWORD | None | Optional Redis auth |
DB_HOST | database.skyhub-prod.internal | PostgreSQL host (port 5432, hardcoded) |
DB_NAME | skyhub | Database name |
DB_USER | idrobots | Database user |
DB_PASSWORD | idrobots | Database password |
lifespan context and closed on shutdown.
Running & deploying
Dockerfile.ecr is identical except it pulls the base image from
public.ecr.aws/docker/library/python:3.10.13 for the AWS build. Dependencies are pinned in
requirements.txt — the notable ones are fastapi, uvicorn, asyncpg (Postgres), redis
(async client), and httpx-ws (the outbound client socket used by the legacy /gamepad bridge). A VS Code
launch config named “Relay” runs the same uvicorn command with --reload.
The auth gap (must-fix TODO)
To close it, a handler must extractaccess_token from websocket.query_params, decode the same HS256
JWT the Gateway issues (see Authentication & JWT Lifecycle), pull user_id,
and confirm ownership — the drone row already carries user_id, so compare it to the token’s subject and
reject with a WebSocket close code on mismatch. Until then, the only thing standing between the control
channel and the open internet is network reachability: in production the proxy sits behind the WireGuard
jumphost like the rest of the stack (VPC, WireGuard Jumphost & nginx Routing).
The
CLAUDE.md “Known Issues” also flags an f-string SQL query at a stale line number. That has since been
fixed — the live query at main.py:67 is parameterized (WHERE id = $1), and the {drone_id:int} path
converter already constrains the input to an integer. The auth gap is the one that is still real.Gotchas for future editors
Two independent control paths — don't route gamepad through the Gateway
Two independent control paths — don't route gamepad through the Gateway
Manual control does not go through the Flask Gateway at all. It is Dashboard → WS Proxy → Redis → drone.
The Gateway’s rosbridge channel (
:9090) carries arm/takeoff/mode/missions and telemetry; the WS Proxy
carries only stick input. Debug them separately.Axes are dropped unless GUIDED control is enabled on the drone
Axes are dropped unless GUIDED control is enabled on the drone
The proxy forwards everything, but the drone-side gamepad service silently ignores axis frames unless the
operator has explicitly enabled guided control AND the vehicle is in GUIDED mode, with a 0.5 s dead-man
timeout. Movement uses
SET_POSITION_TARGET_LOCAL_NED velocity setpoints (zero = hold), deliberately
replacing the dangerous RC_CHANNELS_OVERRIDE. See Guided Velocity Control & Safety Model.Channel keys are IP-scoped, not id-scoped
Channel keys are IP-scoped, not id-scoped
Redis keys are
{drone.ip}:... while the WebSocket path and REST lookup are by numeric drone_id. The
proxy translates id → ip via Postgres on connect; a mid-session IP change won’t be picked up because the
subscription key is bound at connect time.Errors are sent as WebSocket text, then the socket closes
Errors are sent as WebSocket text, then the socket closes
On a lookup failure the handler does
websocket.send_text(str(e)) then close() and re-raises. Clients
should treat an early text frame like Drone not found / Drone IP not found as a fatal error, not data.Related pages
Dashboard: Vehicle Commands
Browser side: gamepad sampling, virtual joystick, and the
/redispad client.Drone OS: Safe Control
How the drone turns gamepad frames into GUIDED velocity setpoints, safely.
Redis Message Bus
The
{ip}: channel convention and the drone-side RedisHandler / MessageRouter.Ecosystem Overview
How the WS Proxy fits alongside Janus, WHIP, SITL, and the User VPN.

