rosbridge :9090
ROS2 topics and services exposed over WebSocket. The Gateway Service’s primary control/telemetry channel.
/gamepad WebSocket
Manual control input and status broadcasts back to the UI, on
GAMEPAD_WS_PORT (default 5001).Redis pub/sub
IP-namespaced channels that glue the ROS half and the agent half together and relay commands from the cloud.
/gamepad WebSocket, and how a single command can arrive at the drone two different ways — over the Redis relay or over a direct WebSocket — yet always land in the same MessageRouter. For the MAVLink side of the stack (mavp2p and the UDP port map) see MAVLink Routing; for the ROS2 module architecture see Module Systems.
Redis channel namespacing
Every per-drone Redis channel is prefixed with the drone’s WireGuardwg0 IP address, e.g. 10.71.4.2:gamepad_input. This is how a single shared Redis (redis.skyhub-prod.internal in production) multiplexes many drones without cross-talk: the Gateway publishes to {that drone's wg0 ip}:gamepad_input, and only that drone’s RedisHandler is subscribed to it.
The IP is resolved once at boot. In docker/gamepad/main.py:86:
docker/gamepad/main.py
get_wg0_ip() (main.py:1145) reads the AF_INET address of the wg0 interface via netifaces. The ROS half’s aruco_landing module resolves it the same way and falls back to 10.223.0.1 for SITL (docker/core/src/modules/aruco_landing/module.py:247).
IP_OVERRIDE has a legacy misspelled alias IP_OVRIDE that is still honored for backwards compatibility (docker/gamepad/src/shared/config.py:34): os.getenv("IP_OVERRIDE") or os.getenv("IP_OVRIDE"). Both must keep working — do not “fix” the typo by deleting the fallback.Global (non-namespaced) channels
Two channels are not IP-prefixed. Thevideo_stream module is IP-agnostic, so its stream-state sync uses a fixed global channel name (docker/core/src/modules/video_stream/module.py:34-35):
Channel catalog
The agent’s channel names are defined indocker/gamepad/src/modules/redis/handler.py:46-55 — eight per-drone channels plus one global. {ip} is the wg0 address (or IP_OVERRIDE).
The agent’s
RedisHandler subscribes to exactly three channels (handler.py:132-136): {ip}:gamepad_input, {ip}:aruco_tracking, and the global video_stream_state. Everything else it only publishes. aruco_tracking and video_stream_state are forwarded straight to connected WebSocket clients; anything else on gamepad_input is handed to MessageRouter.
The ArUco tracking overlay is intentionally not burned into the video stream (the “CANVAS_APROACH”). Coordinates travel over
{ip}:aruco_tracking → the agent’s WebSocket → a frontend canvas overlay, preserving video quality and Jetson GPU. See Video Streaming.The /gamepad WebSocket
The agent half runs a FastAPI app under uvicorn with a single route,/gamepad. The bind port is int(os.getenv("GAMEPAD_WS_PORT", "5001")) (docker/gamepad/main.py:285). Because every drone-side container uses network_mode: host, this port is on the Jetson host directly and is reachable only over the wg0 VPN — there is no auth at the socket layer.
The port used to be the hardcoded constant
5001. Containers can share a host network namespace, so a second vehicle or SITL instance on one host crash-looped on [Errno 98] Address already in use while supervisord kept reporting the process RUNNING. Keep it configurable.MessageRouter.route_message (main.py:645-647), the exact same entry point used by Redis messages:
docker/gamepad/main.py
_broadcast_to_websockets, main.py:613):
In production the Dashboard does not open this socket directly — it goes through the WS Proxy at
{ws_proxy}/redispad/{droneId}?access_token=<jwt>, which authenticates the JWT, resolves the drone IP from Postgres, and relays frames onto {ip}:gamepad_input. The direct :5001 socket exists for VPN-local integrations and debugging. See Vehicle Commands & Gamepad.rosbridge (:9090)
rosbridge is a separate interface entirely. It runs inside theskyhub container under supervisord (docker/core/rosbridge_entrypoint.sh) on ROSBRIDGE_PORT (default 9090):
docker/core/rosbridge_entrypoint.sh
call_service / publish / subscribe frames. It is also how the Gateway pushes /video_room_details and /video_room_state to the on-drone video_stream module. rosbridge does not touch Redis or the agent half. For the connection lifecycle and reconnect behavior on the Gateway side, see Rosbridge Connection & Reconnect.
The dual command path
A control command can reachMessageRouter.route_message two ways, and the router cannot tell them apart beyond the raw type (bytes from Redis vs dict from WebSocket, normalized in message_router.py:166-179):
1
Redis relay (production default)
Gateway or WS Proxy publishes JSON to
{ip}:gamepad_input → RedisHandler._receive_loop → on_message_received → Gamepad._handle_redis_message → MessageRouter.route_message.2
Direct WebSocket (VPN-local)
A client connects to
ws://<drone>:<GAMEPAD_WS_PORT>/gamepad and sends the same JSON → _websocket_endpoint → MessageRouter.route_message.type field, falling back to raw gamepad axes when type is absent (message_router.py:181-202):
Adding a message type
1
Pick a transport
If the command originates in the cloud/UI, publish it to
{ip}:gamepad_input (or send it over the /gamepad socket). No new channel is needed for inbound commands — they all funnel through route_message.2
Add a router branch
Add an
elif msg_type == "your_type": branch in MessageRouter.route_message (message_router.py:181) and a _handle_your_type method. Follow the existing pattern: validate, act, and (if the drone must answer) publish a response.3
Add a response channel only if needed
For a new outbound stream, define the channel in
RedisHandler.__init__ (handler.py:44) as f"{ip_address}:your_channel" and add a publish_* helper. Keep it IP-namespaced unless the data is genuinely drone-agnostic.4
Wire the subscriber
If the gamepad service must consume the new channel, add it to
_configure_subscription (handler.py:132) and route it in _receive_loop (handler.py:265) to a dedicated callback — do not overload on_message_received, which feeds the command router.
