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 service (core MAVROS, gamepad, rtk-ntrip, the e-ArUco lander, an external ground station) 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:139. There is no config file and no env override for it. The port map — 14550 core, 14777 gamepad, 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:139:
docker-compose.yml
mavproxy:
  profiles: ["mavproxy"]
  image: <aws-account-id>.dkr.ecr.eu-central-1.amazonaws.com/skyhub-prod-drone-mavp2p-image:latest
  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:139
14550core MAVROSudpc (client → MAVROS)udp://127.0.0.1:14550@FCUURL.env.example:24, entrypoint.sh:8
14777gamepad / ws_proxyudps (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
14561core 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:139
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 gamepad, 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. This matters for the Isaac SLAM VIO path below, which wants its own serial link.
  • 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 core 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) → 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=1 ros2 launch mavros apm.launch fcu_url:=$FCUURL
entrypoint.sh:8 hardcodes ROS_DOMAIN_ID=1 for the MAVROS launch, ignoring the ROS_DOMAIN_ID env var. Every other process (drone_node, rosbridge, isaac-slam, SITL) honors the env var. If you set ROS_DOMAIN_ID to anything but 1, MAVROS ends up on a different DDS domain and /mavros/* topics vanish for every other node — including the SLAM stack below.
Notice that isaac-slam is absent from the mavp2p port map. It never opens a UDP endpoint on mavp2p. It talks to the vehicle two other ways:
  1. Reading vehicle state over ROS 2 DDS. MavrosStateMonitor subscribes directly to the /mavros/state topic that the core container’s MAVROS already publishes — over the shared DDS domain, not MAVLink (.../navigation/mavros_state_monitor.py:201). Any node on the same ROS_DOMAIN_ID gets /mavros/* for free; re-parsing MAVLink would be redundant.
  2. Injecting VIO pose over a dedicated serial link. pose_bridge_with_covariance.py takes Isaac Visual SLAM’s /visual_slam/tracking/vo_pose, transforms ENU→NED, and sends VISION_POSITION_ESTIMATE frames straight into the FCU — but over its own serial connection at 921600 baud, opened as MAVLink component MAV_COMP_ID_VISUAL_INERTIAL_ODOMETRY:
docker/issac-slam/.../navigation/pose_bridge_with_covariance.py
primary_serial_port: str = "/dev/ttyACM0"
candidate_ports: list[str] = ["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyUSB0"]
baudrate: int = 921600
# ...
self.connection = mavutil.mavlink_connection(
    port, baud=self.config.baudrate,
    source_component=mavutil.mavlink.MAV_COMP_ID_VISUAL_INERTIAL_ODOMETRY,
)
Why serial instead of a 14562-style mavp2p endpoint? The VIO feedback loop is latency- and rate-sensitive (30 Hz VISION_POSITION_ESTIMATE, plus EKF-origin, DO_SET_HOME, and SYSTEM_TIME sync), and ArduPilot’s vision-pose fusion expects it on a dedicated, high-baud link, distinct from the shared telemetry hub. Keeping it off mavp2p isolates the EKF-critical path from congestion on the 115200 serial hub.
Serial-port contention trap. The pose bridge’s default primary_serial_port is /dev/ttyACM0 @ 921600 — the same device mavp2p holds at 115200. Both cannot own /dev/ttyACM0 simultaneously. On a real airframe the FCU exposes a second serial device (the candidate list falls through to /dev/ttyACM1 / /dev/ttyUSB0, or is overridden via the serial_port/baudrate ROS params). Before trusting the serial default, verify which device each side actually opens. Details on the SLAM side: Isaac Visual SLAM & Pose Bridge.

Environment variables

VarDefaultUsed byPurpose
FCUURLudp://127.0.0.1:14550@core MAVROSMAVROS ↔ mavp2p endpoint (apm.launch fcu_url:=)
MAVLINKudpout:127.0.0.1:14777gamepadgamepad MAVLink client → mavp2p
RTK_MAVLINK_CONNECTIONudpout:127.0.0.1:14560rtk-ntripRTCM injection endpoint
ROS_DOMAIN_ID1all ROS2DDS domain; MAVROS launch hardcodes 1 regardless
ROSBRIDGE_PORT9090core 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. Consumers point at the SITL instead:
  • core: FCUURL=udp://:14550@ (bind-only, learns SITL’s address)
  • ws_proxy: 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 exceptions are the host skycore_cli.py and the Isaac pose bridge on its separate high-baud device.
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 gamepad uses MAV_TYPE_GCS, the pose bridge uses MAV_COMP_ID_VISUAL_INERTIAL_ODOMETRY).

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 core container.

SLAM state or VIO missing

Not a mavp2p issue. Check the shared ROS_DOMAIN_ID (must be 1) for /mavros/state, and the pose bridge’s dedicated serial device for VIO injection.

Microservices & Container Profiles

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

Isaac Visual SLAM & Pose Bridge

The DDS + serial VIO path that deliberately bypasses mavp2p.

RTK NTRIP GPS Corrections

The 14560 consumer: RTCM injection and GGA feedback.

Guided Velocity Control & Safety Model

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