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 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.
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 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
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).
If there is no wg0 interface and IP_OVERRIDE is unset, the agent half 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). 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 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):
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

The agent’s channel names are defined in docker/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.
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
Outbound — the agent pushes three kinds of text-JSON broadcasts to all connected clients (_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 the skyhub container under supervisord (docker/core/rosbridge_entrypoint.sh) on ROSBRIDGE_PORT (default 9090):
docker/core/rosbridge_entrypoint.sh
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 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 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_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.
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):
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”

The router logs every inbound message with its type and whether it carries axes (message_router.py:169-176). Grepping the skyhub 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).