The WS Proxy (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 :5001 or Redis): raw manual stick input, 60 Hz.
The /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

websocket_redis_proxymain.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.
  1. Accept the client WebSocket, resolve drone_ip from PostgreSQL.
  2. Subscribe a Redis pub/sub to {drone_ip}:output and {drone_ip}:aruco_tracking.
  3. Client → drone: every text frame received from the browser is published to {drone_ip}:gamepad_input.
  4. Drone → client: every message on the subscribed channels is decoded (UTF-8) and forwarded to the browser.
Both directions run concurrently under a single asyncio.gather(...); on disconnect the pub/sub is unsubscribed and closed in a finally block.
skyhub_ws_proxy/main.py
pubsub = redis.pubsub()
await pubsub.subscribe(
    f"{drone_ip}:output",
    f"{drone_ip}:aruco_tracking",
)

async def forward_to_redis():
    while True:
        text = await websocket.receive_text()
        await redis.publish(f"{drone_ip}:gamepad_input", text)

async def forward_to_websocket():
    async for message in pubsub.listen():
        if message["type"] == "message":
            await websocket.send_text(message["data"].decode("utf-8"))

Drone IP lookup

Both relay modes and the REST endpoint resolve the target drone the same way — a single parameterized query against the shared drone table (main.py:61):
SELECT id AS drone_id, user_id, ip FROM drone WHERE id = $1
The connection uses an 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.
ChannelDirectionWritten byRead byPayload
{drone_ip}:gamepad_inputclient → droneWS Proxy (publish)drone gamepad serviceGamepad/joystick frames (axes + buttons) as text
{drone_ip}:outputdrone → clientdrone gamepad serviceWS Proxy (subscribe)Status/ack messages routed back to the UI
{drone_ip}:aruco_trackingdrone → clientdrone precision-landing moduleWS Proxy (subscribe)ArUco marker tracking data for the frontend canvas overlay
The channel keys are namespaced by the drone’s IP string, not its numeric id — e.g. 10.71.0.7:gamepad_input. This is the same {ip}: convention SkyCore uses for all its Redis channels. If a drone’s IP changes, in-flight subscriptions on the old key go silent. The proxy never inspects the payload; it only moves bytes, so the frame schema is owned entirely by the Dashboard and the drone gamepad module (Core & Gamepad Module Systems).
Note the proxy does not subscribe to {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

GET /drone/{drone_id}
JSON
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.
curl http://ws_proxy.skyhub-prod.internal:7070/drone/7
# {"drone_id": 7, "user_id": 3, "ip": "10.71.0.7"}

Client connection

The Dashboard’s VehicleCommandService opens the socket like this (skyhub_dashboard/src/app/services/vehicle-command.service.ts:63):
const wsUrl = `${environment.ws_proxy}/redispad/${droneId}?access_token=${encodeURIComponent(accessToken)}`;
const socket = new WebSocket(wsUrl);
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 in main.py:12-17. The defaults target the production Cloud Map service names.
VariableDefaultPurpose
REDIS_HOSTredis.skyhub-prod.internalRedis host for pub/sub (port 6379, db 0, hardcoded)
REDIS_PASSWORDNoneOptional Redis auth
DB_HOSTdatabase.skyhub-prod.internalPostgreSQL host (port 5432, hardcoded)
DB_NAMEskyhubDatabase name
DB_USERidrobotsDatabase user
DB_PASSWORDidrobotsDatabase password
Pool sizing is fixed in code: the asyncpg pool is min 2 / max 10, and the Redis client caps at 20 connections. Both pools are created in the FastAPI lifespan context and closed on shutdown.

Running & deploying

# hot reload on :7070
uvicorn main:app --reload --port 7070 --host 0.0.0.0
uvicorn main:app --host 0.0.0.0 --port 7070
# skyhub_ws_proxy/Dockerfile — python:3.10.13
EXPOSE 7070
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7070"]
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)

The WS Proxy performs no authentication or ownership check. The Dashboard appends ?access_token=<jwt> to the URL, but main.py never reads that query parameter — there is no JWT decode and no verification that the connecting user actually owns drone_id. Anyone who can reach :7070 and guess an integer drone id can open /redispad/{id} and publish to {ip}:gamepad_input, i.e. drive someone else’s vehicle. This is the repo’s headline known issue (skyhub_ws_proxy/CLAUDE.md, “Known Issues”).
To close it, a handler must extract access_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

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.
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.
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.
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.

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.