rosbridge :9090
ROS2 topics and services exposed over WebSocket. The Gateway Service’s primary control/telemetry channel.
/gamepad :5001
FastAPI WebSocket for manual control input and status broadcasts back to the UI.
Redis pub/sub
IP-namespaced channels that glue core, gamepad, and isaac-slam 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 Core & Gamepad 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 core service’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. The corevideo_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 | Direction | Purpose |
|---|---|---|
video_stream_state | core → gamepad → UI | Stream start/stop sync ({type, is_streaming, reason, room_id, timestamp}) |
video_stream_status_request | gamepad → core | UI-triggered request for the current stream status |
Channel catalog
Channel names for the gamepad service are defined indocker/gamepad/src/modules/redis/handler.py:44-54. The full bus also carries channels published by the core and isaac-slam services. {ip} is the wg0 address (or IP_OVERRIDE).
| Channel | Namespaced | Publisher(s) | Subscriber(s) | Purpose |
|---|---|---|---|---|
{ip}:gamepad_input | yes | Gateway/WS Proxy, core aruco_landing, isaac docking | gamepad RedisHandler | Inbound control bus — re-enters MessageRouter identically to a WebSocket message |
{ip}:output | yes | gamepad | Gateway/UI | Telemetry & status: camera_status (recording), guided_control_status, general output |
{ip}:chat | yes | gamepad, isaac audio triggers | Gateway/UI, isaac AudioListener | Chat + system notices |
{ip}:restart_control | yes | gamepad (system_command) | Gateway/UI | System commands (restart, dock_stop, …) |
{ip}:slam_control | yes | gamepad (slam_command) | isaac-slam | SLAM/navigation commands — see SLAM & Pose Bridge |
{ip}:charging_status | yes | gamepad ChargingModule | Gateway/UI | Charging relay ON/OFF status |
{ip}:camera_status | yes | gamepad / isaac | Gateway/UI | Recording state (helper publish_camera_status) |
{ip}:audio | yes | gamepad, mission events | isaac AudioListener (TTS) | TTS announcement triggers |
{ip}:aruco_tracking | yes | core aruco_landing | gamepad → WS clients | ArUco marker tracking for the frontend canvas overlay |
{ip}:mode_change / {ip}:armed_state | yes | isaac MavrosStateMonitor | Gateway/UI | Flight-mode & armed transitions |
{ip}:docking_status | yes | isaac DockingService | Gateway/UI | Rover docking state — see Detection & Landing |
video_stream_state | no (global) | core video_stream | gamepad → WS clients | Stream on/off sync |
video_stream_status_request | no (global) | gamepad MessageRouter | core video_stream | Request current stream status |
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 → gamepad WebSocket → a frontend canvas overlay, preserving video quality and Jetson GPU. See Video Streaming.The /gamepad WebSocket (:5001)
The gamepad service runs a FastAPI app under uvicorn on0.0.0.0:5001 with a single route, /gamepad (docker/gamepad/main.py:180, main.py:208-209). 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.
Inbound — each text frame is parsed as JSON and passed to 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):
Broadcast type | Trigger | Source |
|---|---|---|
aruco_tracking | Redis {ip}:aruco_tracking message | core aruco_landing |
video_stream_state | Redis global video_stream_state message | core video_stream |
guided_control_state | Local state change in the guided-control module | _handle_guided_state_change, main.py:596 |
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 thecore 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 gamepad service. 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):
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.type field, falling back to raw gamepad axes when type is absent (message_router.py:181-202):
type | Handler | Effect |
|---|---|---|
chat | _handle_chat | Publishes to {ip}:chat |
system_command | _handle_system_command | Publishes to {ip}:restart_control; dock_stop also unlocks the gimbal |
charging_control | _handle_charging_control | start/stop/toggle/status on the charging relay (Jetson GPIO); status → {ip}:charging_status |
slam_command | _handle_slam_command | Publishes to {ip}:slam_control |
camera_command | _handle_camera_command | Photo / record / focus / zoom / gimbal via SIYI or MAVLink |
guided_control | _handle_guided_control | enable/disable/status for velocity control; status → {ip}:output |
velocity_command | _handle_velocity_command | Direct body-frame velocity (only if guided control is enabled) |
video_stream_status_request | _handle_video_stream_status_request | Publishes to global video_stream_status_request |
(no type, has axes) | _handle_gamepad_input | Raw sticks/buttons: L1 gimbal-mode toggle, L2/R2 zoom, guided velocity |
Adding a message type
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.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.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.Debugging: “my message didn’t arrive”
| Symptom | Likely cause |
|---|---|
| Command published but nothing happens on the drone | Wrong IP prefix. Confirm the drone’s wg0 IP and that you published to {that_ip}:gamepad_input, not a stale/other IP |
gamepad service crash-loops at boot with RuntimeError | No wg0 interface and IP_OVERRIDE/IP_OVRIDE unset (SITL/dev) |
| Velocity/axes commands ignored | Guided control not enabled, or vehicle not in GUIDED mode — both guards must pass |
video_stream_state seen on the wrong drone | Expected: that channel is global, not IP-namespaced |
No aruco_tracking / video_stream_state reaching the UI | No WebSocket client connected, or the async event loop wasn’t captured yet — broadcasts are dropped when websocket_connections is empty (main.py:573, main.py:589) |
| Redis reconnect storms in logs | DNS for REDIS_HOST not resolving; RedisHandler checks resolution before connecting and backs off exponentially (handler.py:64-118) |
| Arm/takeoff/mode commands fail but gamepad works | Those go over rosbridge :9090, not this bus — check the Gateway’s rosbridge connection |

