SkyCore’s on-drone services do not talk to each other over a single fabric. Three transports coexist, each with a different owner, a different security assumption, and a different failure mode:

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.
This page documents the Redis channel catalog, the /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 WireGuard wg0 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
self.my_ip = self.config.IP_OVERRIDE or self.get_wg0_ip()
if not self.my_ip:
    raise RuntimeError("Could not get wg0 IP address")
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).
If there is no wg0 interface and IP_OVERRIDE is unset, the gamepad service raises RuntimeError at startup and never comes up. In SITL/dev there is no real VPN, so you must set IP_OVERRIDE (the local overlay injects 10.223.1.1 / 10.223.1.2). See Local Development with SITL.
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 core video_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):
ChannelDirectionPurpose
video_stream_statecore → gamepad → UIStream start/stop sync ({type, is_streaming, reason, room_id, timestamp})
video_stream_status_requestgamepad → coreUI-triggered request for the current stream status
Because these two channels are global, a multi-drone deployment sharing one Redis can cross-talk on video-stream state. This is a known constraint — preserve the per-drone namespacing on every other channel to keep drones isolated.

Channel catalog

Channel names for the gamepad service are defined in docker/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).
ChannelNamespacedPublisher(s)Subscriber(s)Purpose
{ip}:gamepad_inputyesGateway/WS Proxy, core aruco_landing, isaac dockinggamepad RedisHandlerInbound control bus — re-enters MessageRouter identically to a WebSocket message
{ip}:outputyesgamepadGateway/UITelemetry & status: camera_status (recording), guided_control_status, general output
{ip}:chatyesgamepad, isaac audio triggersGateway/UI, isaac AudioListenerChat + system notices
{ip}:restart_controlyesgamepad (system_command)Gateway/UISystem commands (restart, dock_stop, …)
{ip}:slam_controlyesgamepad (slam_command)isaac-slamSLAM/navigation commands — see SLAM & Pose Bridge
{ip}:charging_statusyesgamepad ChargingModuleGateway/UICharging relay ON/OFF status
{ip}:camera_statusyesgamepad / isaacGateway/UIRecording state (helper publish_camera_status)
{ip}:audioyesgamepad, mission eventsisaac AudioListener (TTS)TTS announcement triggers
{ip}:aruco_trackingyescore aruco_landinggamepad → WS clientsArUco marker tracking for the frontend canvas overlay
{ip}:mode_change / {ip}:armed_stateyesisaac MavrosStateMonitorGateway/UIFlight-mode & armed transitions
{ip}:docking_statusyesisaac DockingServiceGateway/UIRover docking state — see Detection & Landing
video_stream_stateno (global)core video_streamgamepad → WS clientsStream on/off sync
video_stream_status_requestno (global)gamepad MessageRoutercore video_streamRequest current stream status
The gamepad 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 on 0.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
data = await websocket.receive_text()
input_data = json.loads(data)
self.message_router.route_message(input_data)
Outbound — the service pushes three kinds of text-JSON broadcasts to all connected clients (_broadcast_to_websockets, main.py:613):
Broadcast typeTriggerSource
aruco_trackingRedis {ip}:aruco_tracking messagecore aruco_landing
video_stream_stateRedis global video_stream_state messagecore video_stream
guided_control_stateLocal 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 the core container under supervisord (docker/core/rosbridge_entrypoint.sh) on ROSBRIDGE_PORT (default 9090):
docker/core/rosbridge_entrypoint.sh
ROSBRIDGE_PORT=${ROSBRIDGE_PORT:-9090}
ros2 launch rosbridge_server rosbridge_websocket_launch.xml port:="${ROSBRIDGE_PORT}"
This is the channel the Gateway Service uses to publish/subscribe ROS2 topics and call services — arm, takeoff, set mode, mission push, and all telemetry streaming flow here as rosbridge 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 reach MessageRouter.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_inputRedisHandler._receive_loopon_message_receivedGamepad._handle_redis_messageMessageRouter.route_message.
2

Direct WebSocket (VPN-local)

A client connects to ws://<drone>:5001/gamepad and sends the same JSON → _websocket_endpointMessageRouter.route_message.
Both paths carry the identical message format, so anything you can do over one you can do over the other. The router dispatches by a type field, falling back to raw gamepad axes when type is absent (message_router.py:181-202):
typeHandlerEffect
chat_handle_chatPublishes to {ip}:chat
system_command_handle_system_commandPublishes to {ip}:restart_control; dock_stop also unlocks the gimbal
charging_control_handle_charging_controlstart/stop/toggle/status on the charging relay (Jetson GPIO); status → {ip}:charging_status
slam_command_handle_slam_commandPublishes to {ip}:slam_control
camera_command_handle_camera_commandPhoto / record / focus / zoom / gimbal via SIYI or MAVLink
guided_control_handle_guided_controlenable/disable/status for velocity control; status → {ip}:output
velocity_command_handle_velocity_commandDirect body-frame velocity (only if guided control is enabled)
video_stream_status_request_handle_video_stream_status_requestPublishes to global video_stream_status_request
(no type, has axes)_handle_gamepad_inputRaw sticks/buttons: L1 gimbal-mode toggle, L2/R2 zoom, guided velocity
velocity_command and raw axes are silently dropped unless guided control has been enabled and the vehicle is in GUIDED mode (message_router.py:535-536, _handle_gamepad_input). Zero velocity means “hold”, not “cut throttle”. This is the core safety contract — see Guided Velocity Control & Safety Model.

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.

Debugging: “my message didn’t arrive”

SymptomLikely cause
Command published but nothing happens on the droneWrong 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 RuntimeErrorNo wg0 interface and IP_OVERRIDE/IP_OVRIDE unset (SITL/dev)
Velocity/axes commands ignoredGuided control not enabled, or vehicle not in GUIDED mode — both guards must pass
video_stream_state seen on the wrong droneExpected: that channel is global, not IP-namespaced
No aruco_tracking / video_stream_state reaching the UINo 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 logsDNS 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 worksThose go over rosbridge :9090, not this bus — check the Gateway’s rosbridge connection
The router logs every inbound message with its type and whether it carries axes (message_router.py:169-176). Grepping the gamepad container logs for [Router] is the fastest way to confirm a command actually reached route_message versus being lost upstream (Redis prefix, WS Proxy auth, or VPN).