Skip to content

Your first hover hop

In this tutorial you fly ArduPilot’s stock quadplane through mcardupilot’s tools: take off vertically, climb to 5 m, hover for fifteen seconds, land, and then read the flight back out of its log. It takes about ten minutes, most of it reading.

Every result shown here comes from a real run on the stock quadplane SITL, trimmed to the fields that matter. Your numbers will differ in the last digits.

You need an ArduPilot checkout with the plane SITL built, uv, and an MCP client. The examples use Claude Code.

Terminal window
# in your ArduPilot checkout (mcardupilot looks in ~/ardupilot unless told otherwise)
./waf configure --board sitl
./waf plane # builds build/sitl/bin/arduplane

If your tree lives somewhere else, set MCARDUPILOT_ARDUPILOT to its path. Install and connect covers the options.

  1. Add the server to Claude Code.

    Terminal window
    # from PyPI
    claude mcp add mcardupilot -- uvx mcardupilot
    # or from a local checkout
    claude mcp add mcardupilot -- uv run --directory /path/to/mcardupilot mcardupilot

    Start a new Claude Code session and ask for server_info. It should report binary_exists: true and the instance pool, 40-59 by default.

  2. Open a SITL.

    open_sitl(model="quadplane", label="first-hop", idle_speedup=0.05)
    {
    "session_id": "7ae354",
    "instance": 40,
    "mavlink_port": 6160,
    "defaults": [".../Tools/autotest/default_params/quadplane.parm"],
    "provenance": { "ardupilot_sha": "4c98c92", "ardupilot_patch_sha256": null },
    "snapshot": {
    "state": "open", "seq": 1184, "t_sim_s": 10.224, "speedup": 10.0,
    "armed": false, "mode": "FBWA", "height_m": 0.17, "landed": true,
    "rc": [1500, 1500, 1000, 1500, 1800, 1500, 1500, 1500]
    }
    }

    The call returned once home was set. You got the first free instance in the pool, and its MAVLink port follows from the number: 5760 + 10 × 40 = 6160. Because params was empty, the server loaded the frame’s own defaults from the tree, the same file sim_vehicle.py -f quadplane would load. From now on a thread inside the server owns the link and keeps the GCS heartbeat and RC overrides flowing between your calls.

    Keep the session_id. Every other session tool takes it.

  3. Arm in QLOITER.

    arm(session_id="7ae354", mode="QLOITER")
    {
    "armed": true,
    "refused": null,
    "snapshot": { "t_sim_s": 49.673, "mode": "QLOITER", "armed": true, "height_m": 0.0,
    "texts": [ { "text": "AHRS: EKF3 active" }, { "text": "Throttle armed" } ] }
    }

    arm drops the throttle, switches mode and retries every 3 sim seconds while the pre-arm checks settle. Here that took about 40 sim seconds, the time the EKF needs to start using GPS. Heights from now on are measured from where it armed.

  4. Push the throttle up.

    rc_override(session_id="7ae354", channels={3: 2000})
    { "rc": [1500, 1500, 2000, 1500, 1800, 1500, 1500, 1500], "enabled": true, "script_channels": [] }

    Channel 3 is the throttle stick. In QLOITER, stick above centre climbs and stick at centre holds height. The server now sends 2000 on channel 3 with every message it pumps, until something changes it.

    Before you go on, think about what happens next. The vehicle is climbing, and it keeps climbing until a later call moves the stick back.

  5. Stop the climb at 5 m, inside the server.

    wait_until(session_id="7ae354", condition="altitude", value=5, then_rc={3: 1500})
    {
    "satisfied": true, "reason": "met", "snapshot_at": "settle",
    "waited_sim_s": 3.3, "waited_wall_s": 0.38,
    "snapshot": {
    "seq": 6905, "t_sim_s": 53.022, "speedup": 0.05, "mode": "QLOITER",
    "height_m": 5.0, "climb_mps": 2.80, "throttle_pct": 66.0,
    "rc": [1500, 1500, 1500, 1500, 1800, 1500, 1500, 1500]
    }
    }

    The stick went back to centre in the same tick the vehicle crossed 5 m, and the snapshot was taken in that tick too, so height_m: 5.0 is a measurement at that moment rather than a look a call later. speedup in the snapshot reads 0.05: that is idle_speedup at work, which the next section explains.

  6. Hover for 15 sim seconds.

    wait_until(session_id="7ae354", condition="sim_time", value=15, relative=True)
    {
    "satisfied": true, "reason": "met", "waited_sim_s": 15.05, "waited_wall_s": 1.56,
    "snapshot": { "t_sim_s": 68.071, "mode": "QLOITER", "height_m": 7.3, "climb_mps": -0.006 }
    }

    relative=True counts from the tick the previous wait settled, so the hover is fifteen seconds of autopilot time from the moment the stick centred, however long you took to send this call. Notice the height: 7.3 m, not 5. The vehicle was climbing at 2.8 m/s when the stick centred, and QLOITER braked it to a stop above the target. The figure below shows it.

  7. Land.

    set_mode(session_id="7ae354", mode="QLAND")
    wait_until(session_id="7ae354", condition="disarmed", timeout_s=120)
    { "satisfied": true, "reason": "met", "waited_sim_s": 20.8,
    "snapshot": { "t_sim_s": 88.922, "mode": "QLAND", "armed": false, "height_m": 0.0, "landed": true } }

    set_mode returns only once the autopilot’s own heartbeat reports QLAND. The wait ends when ArduPilot disarms itself after detecting the landing.

  8. Close the session.

    close_sitl(session_id="7ae354")
    { "session_id": "7ae354", "instance": 40, "kept": [".../logs/20261003T202812Z-first-hop.BIN"], "pruned": [], "state": "closed" }

    SITL’s whole process group is killed (it ignores SIGTERM), the server waits for its ports to come free, the lease on instance 40 is released, and the dataflash log moves out of the instance’s run directory into the kept-log directory under the label you gave.

Tool calls are not free in sim time. SITL runs at speedup times real time, 10 by default, and it cannot pause. Everything you do between two calls, reading a result, deciding, writing the next call, is flight time with the last RC still held:

your turn4 s
the aircraft40 sim s, last RC still held
At speedup 10, a 4 second pause between two tool calls is 40 seconds of flight. A climb at 2.9 m/s that you meant to stop at 5 m is past 100 m by the time the next call lands.

That is why step 5 put the reaction inside the wait. then_rc, then_mode and then_speedup are applied by the server in the tick the condition is met, so your own round trip never separates the condition from the response. And it is why step 6 waited on sim_time instead of counting seconds from whenever the call arrived.

idle_speedup=0.05 is the other half. With it set, the session runs at full speed only while a call is in flight and at a twentieth of real time otherwise, so the moment a wait settles the sim nearly stops and waits for you. For a flight you pace by hand, it makes your thinking time almost irrelevant. For a manoeuvre with many steps, a step program takes the round trips out altogether.

Height against sim time for the tutorial's hover hopArmed at 49.67 s, the climb at full stick reaches 5 m at 53.02 s, where then_rc centres the throttle in the same tick. The vehicle brakes and peaks at 7.48 m at 54.36 s, holds near 7.3 m through the 15 s hover, then descends in QLAND and disarms at 88.92 s.climbhover 15 sQLAND024685060708090sim time, sheight, mthen_rc {3: 1500} at 5.00 mbrakes to 7.5 m
The hop from the tutorial, read back with log_fields (POS, RelHomeAlt). The stick centred in the tick the vehicle crossed 5 m, and QLOITER then braked a climb of about 2.9 m/s, so it settled near 7.3 m. Stock quadplane SITL at speedup 10.

The kept log is a few MB of ArduPilot dataflash. Ask for the summary by its name:

log_summary(log="20261003T202812Z-first-hop.BIN")
{
"firmware": "ArduPlane V4.8.0-dev (4c98c922)",
"duration_s": 78.149,
"energy_wh": null,
"lift_motor_channels": [5, 6, 7, 8],
"phases": [
{ "t0_s": 10.804, "t1_s": 49.527, "mode": "QLOITER", "armed": false,
"lift_pct": 0.0, "alt_max_m": 0.31 },
{ "t0_s": 49.527, "t1_s": 68.098, "mode": "QLOITER", "armed": true,
"lift_pct": 72.9, "alt_max_m": 7.49, "roll_abs_max_deg": 0.36, "climb_mps": 0.398 },
{ "t0_s": 68.098, "t1_s": 88.729, "mode": "QLAND", "armed": true,
"lift_pct": 58.8, "alt_max_m": 7.41, "climb_mps": -0.351 },
{ "t0_s": 88.729, "t1_s": 88.953, "mode": "QLAND", "armed": false, "lift_pct": 0.0 }
],
"events": [
{ "t_s": 49.527, "text": "EV armed" },
{ "t_s": 82.601, "text": "SIM Hit ground at 0.502843 m/s" },
{ "t_s": 88.729, "text": "Land complete" },
{ "t_s": 88.729, "text": "EV disarmed" }
]
}

The flight splits at every mode change, arm and disarm. The lift motors were found from the log’s own SERVOn_FUNCTION parameters, so lift_pct is the average of channels 5 to 8 above idle. The touchdown at 82.6 s and the land-complete six seconds later are ArduPilot’s landing detector taking its time to be sure.

energy_wh is null because the stock quadplane parameters configure no battery monitor. Open the next flight with set={"BATT_MONITOR": 4} and the summary reports current, voltage and energy per phase.

The first phase, QLOITER and disarmed for 38.7 s, is the arming wait from step 3: arm switched the mode at once and then retried until the pre-arm checks passed.

You flew the whole hop one call at a time. Fly a manoeuvre as one step program runs the same hop as a single call, Run a gusty A/B test adds wind and compares two flights, and Read a flight back goes further into the log tools.