On the drone there is exactly one process that talks to the ArduPilot flight controller over the physical serial link: the mavproxy compose service, which runs mavp2p — a lightweight, stateless MAVLink multiplexer. Every other consumer (MAVROS, the agent, rtk-ntrip, the e-ArUco lander, an external ground station — the first three all inside the skyhub container) is a UDP client that connects to a mavp2p endpoint. mavp2p forwards each MAVLink frame from the serial FCU out to every UDP endpoint and forwards frames from any endpoint back to the FCU — a full mesh with the serial port at the hub. This page is the load-bearing port map. If a service can’t reach the FCU, the answer is almost always here: the endpoint list is a hardcoded CLI argument in docker-compose.yml, not env-configurable, and each consumer’s connection string must match it byte-for-byte.
The mavp2p endpoint list lives only as command: args on the mavproxy service in docker-compose.yml:184. There is no config file and no env override for it. The port map — 14550 MAVROS, 14777 agent, 14560 rtk, 14561 aruco, 14900 external — is a hard contract. Change a port on one side without the other and the link silently goes dead (no error; frames just go nowhere).

The endpoint map

Exact command from docker-compose.yml:184:
docker-compose.yml
mavproxy:
  profiles: ["mavproxy"]
  image: <aws-account-id>.dkr.ecr.eu-central-1.amazonaws.com/skyhub-prod-drone-mavp2p-image:latest
  # built from docker/mavp2p/Dockerfile via buildspec/mavp2p.yml — FROM scratch, 7.2 MB
  network_mode: host
  restart: always
  command: ["serial:/dev/ttyACM0:115200", "udps:0.0.0.0:14900", "udpc:127.0.0.1:14550", "udps:0.0.0.0:14777", "udps:0.0.0.0:14560", "udps:0.0.0.0:14561"]
PortConsumermavp2p roleConsumer connection stringConfigured bySource
serialArduPilot FCUserial /dev/ttyACM0:115200— (physical link)command: argdocker-compose.yml:184
14550skyhub MAVROSudpc (client → MAVROS)udp://127.0.0.1:14550@FCUURL.env.example:24, docker/core/entrypoint.sh
14777skyhub agent halfudps (server)udpout:127.0.0.1:14777MAVLINK.env.example:177, gamepad/src/shared/config.py:15
14560rtk-ntripudps (server)udpout:127.0.0.1:14560RTK_MAVLINK_CONNECTION.env.example:167, rtk-ntrip/src/shared/config.py:20
14561skyhub e-ArUco landerudps (server)udpout:127.0.0.1:14561DEFAULT_MAVLINK_CONNECTIONcore/src/modules/aruco_landing/controller.py:45
14900external GCS / debugudps (server)e.g. udp:<drone-vpn-ip>:14900operator’s GCSdocker-compose.yml:184
Why one endpoint is udpc and the rest are udps. MAVROS is launched with FCUURL=udp://127.0.0.1:14550@, whose trailing @ makes MAVROS bind UDP/14550 as a server. So mavp2p must reach out to it as a client — hence udpc:127.0.0.1:14550. The agent, rtk, and aruco consumers all use udpout: (pymavlink’s “connect out as a client”), so mavp2p must be the server for them — hence udps:0.0.0.0:14777/14560/14561. Flip either side’s role and the socket never pairs up.
Because all services run network_mode: host, 127.0.0.1 is the shared host loopback — every container sees the same MAVLink ports on localhost. There is no Docker port remapping. (See Microservices & Container Profiles for the host-networking model and Redis Message Bus & WebSocket Interfaces for the non-MAVLink channels.) The single serial endpoint serial:/dev/ttyACM0:115200 is the physical USB link to the Cube/Pixhawk running ArduPilot. Two things about it are worth pinning down:
  • /dev/ttyACM0 is owned by mavp2p at 115200 baud. Nothing else may open that device while mavp2p is up.
  • The host skycore_cli.py does not go through mavp2p. It is a maintenance tool that opens its own MAVLink connection, auto-probing /dev/ttyACM*, /dev/ttyUSB*, and UDP 14550/14551 directly on the host — outside the container topology. See SkyCore CLI Reference.

MAVROS stream-rate bootstrap

Getting frames routed to 14550 is necessary but not sufficient: ArduPilot will not stream sensor topics at a useful rate until a ground station asks it to. Inside the skyhub container this is handled by set_stream_rates.sh, a one-shot step run under supervisord after MAVROS comes up. The startup order is encoded as supervisord priority values (supervisord.conf): sshd(50) → MAVROS(100) → rosbridge(200) → stream_rates(300) → agent(400) → drone_node(999). Reordering these can break the /mavros/state subscription or the stream-rate call. set_stream_rates.sh does three things, with a 120-second overall timeout:
1

Wait for the MAVROS state topic

Polls ros2 topic list until /mavros/state appears.
2

Wait for FCU connection

Echoes /mavros/state until it reads connected: true, then waits 5s for MAVROS plugins to finish initializing.
3

Request all streams at 10 Hz

Calls the MAVROS set_stream_rate service with stream id 0 (MAV_DATA_STREAM_ALL):
docker/core/set_stream_rates.sh
ros2 service call /mavros/set_stream_rate mavros_msgs/srv/StreamRate \
    "{stream_id: 0, message_rate: 10, on_off: true}"
Without this step, topics like /mavros/global_position/global, /mavros/battery, and /mavros/vfr_hud publish slowly or not at all — which is why telemetry can look “dead” in the UI even when rosbridge (:9090) is connected. The Gateway subscribes to those same topics over rosbridge; see DroneControlService & Rosbridge Dispatch.
stream_rates is configured autorestart=false / startretries=1. It runs once and exits 0; a non-zero exit (e.g. the FCU never reported connected: true within 120s) is a strong signal the mavp2p ↔ FCU serial link is down, not a ROS problem.
MAVROS itself is launched by entrypoint.sh:
docker/core/entrypoint.sh
ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-1} ros2 launch mavros apm.launch fcu_url:=$FCUURL
The domain is per-vehicle and honoured here. It used to be hardcoded to 1 on this line, which stranded MAVROS on a different domain from rosbridge for every vehicle after the first and made all /mavros/* services invisible to the Gateway.

Environment variables

VarDefaultUsed byPurpose
FCUURLudp://127.0.0.1:14550@skyhub MAVROSMAVROS ↔ mavp2p endpoint (apm.launch fcu_url:=)
MAVLINKudpout:127.0.0.1:14777skyhub agentagent MAVLink client → mavp2p
RTK_MAVLINK_CONNECTIONudpout:127.0.0.1:14560rtk-ntripRTCM injection endpoint
ROS_DOMAIN_ID1all ROS2DDS domain shared by MAVROS, rosbridge and drone_node; honoured everywhere
ROSBRIDGE_PORT9090skyhub rosbridgeROS↔WebSocket port the Gateway connects to
The e-ArUco lander’s udpout:127.0.0.1:14561 default is a hardcoded class constant (controller.py:45) rather than something defined in .env.example, but it can be overridden via the ARUCO_MAVLINK_CONNECTION env var (controller.py:65). See MAVROS/MAVLink env vars in the platform reference for the full list.

Deployment divergences a future editor must preserve

docker/docker-compose.installer.yml (the templated first-boot compose) runs mavp2p on a different device and baud and with only three UDP endpoints:
docker/docker-compose.installer.yml
command: ["serial:/dev/ttyUSB1:230400", "udps:0.0.0.0:14900", "udpc:127.0.0.1:14550", "udps:0.0.0.0:14777"]
No 14560 (rtk) and no 14561 (aruco). A drone provisioned from the installer compose will silently fail RTK and e-ArUco landing until it is upgraded to the full docker-compose.yml. Don’t assume the two composes agree.
Under docker-compose.local.yml the mavproxy service is disabled (moved to profiles: ["disabled"]) — there is no serial FCU and no mavp2p. The skyhub-sitl container is the ArduPilot FCU + MAVROS + rosbridge. Both consumer env vars now sit on the single skyhub service in docker-compose.local.yml and point at the SITL instead:
  • FCUURL=udp://:14550@ (bind-only, learns SITL’s address) — note the SITL router’s own core endpoint is 14600 (CORE_MAVLINK_PORT)
  • MAVLINK=udpout:host.docker.internal:14777
So the same port numbers apply, but the hub is the SITL container, not mavp2p. See Local Development with SITL & Field Uplink.
Port 14900 (udps) has no in-repo consumer. It is the external/GCS debug endpoint: point QGroundControl or MAVProxy at udp:<drone-wg0-ip>:14900 over the WireGuard VPN to watch or command the FCU directly. It is intentionally always present in the port map even though nothing in SkyCore binds it.
1

Pick a free UDP port

Extend the mavp2p command: list in docker-compose.yml with a new udps:0.0.0.0:<port> (use udps unless your consumer binds/listens itself, in which case use udpc). Mirror the change in docker-compose.installer.yml if the feature must survive first boot.
2

Connect as a client

Point your service at udpout:127.0.0.1:<port> (via a new env var, following the MAVLINK / RTK_MAVLINK_CONNECTION pattern).
3

Do not touch the FCU serial line

Never open /dev/ttyACM0 directly — go through mavp2p. The only sanctioned exception is the host skycore_cli.py.
4

Give yourself a unique source_system / component

Every mavp2p client shares one FCU; pick a source system/component id that won’t collide (the agent uses MAV_TYPE_GCS).

Nothing reaches the FCU at all

Check the mavproxy container is up and holds /dev/ttyACM0. If set_stream_rates.sh timed out (connected: true never seen), the serial/USB link — not ROS — is the problem.

One service is blind

Compare that service’s connection string against the port map above. A udpout: pointed at a port mavp2p isn’t serving (e.g. 14560 on an installer-compose drone) fails silently.

Telemetry topics are slow/empty

MAVLink is flowing but stream rates weren’t set — inspect the stream_rates supervisord log inside the skyhub container.

Microservices & Container Profiles

The compose services, host networking, and startup ordering that surround mavp2p.

Redis Message Bus

The non-MAVLink command and status channels between the two halves.

RTK NTRIP GPS Corrections

The 14560 consumer: RTCM injection and GGA feedback.

Guided Velocity Control & Safety Model

The 14777 agent consumer and its GUIDED-mode velocity safety guards.