Skip to main content

mcardupilot

ArduPilot SITL instances, flights and dataflash logs for LLM agents, as a Python library with an MCP server on top of it.

Several sessions on one workstation fly SITL at the same time. Each SITL instance number picks its ports (MAVLink TCP 5760 + 10n, the JSON backend 9002 + 10n), so mcardupilot hands out instances from a shared pool through flock leases. A lease names its holder, and the kernel drops it when the holder exits, so a crashed run never strands an instance.

Status: phases 0 to 4 of docs/PLAN.md: settings, leases, the Sitl library class, missions, log housekeeping, the MCP flight tools, and log reading.

Documentation

The documentation site lives in docs-site/ (Astro and Starlight), to be published at https://mcardupilot.warehack.ing: a tutorial, how-to guides, a tool and settings reference generated from the code (make -C docs-site data), and explanations of the design.

Flying through the tools

open_sitl starts a SITL on a leased instance and returns a session id. A thread per session owns the MAVLink link and keeps it alive between calls (GCS heartbeat and RC overrides, paced on sim time), so the failsafes stay quiet while the agent thinks. A hover hop:

open_sitl(model="quadplane")                       # frame defaults from the tree's vehicleinfo.json
arm(session_id, mode="QLOITER")
rc_override(session_id, channels={3: 2000})        # climb
wait_until(session_id, "altitude", 5, then_rc={3: 1500})   # hold at 5 m, applied in the same tick
set_mode(session_id, "QLAND")
wait_until(session_id, "disarmed", timeout_s=120)
close_sitl(session_id)                             # SITL killed, instance freed, BIN kept

wait_until returns within about 85 s with a reason (met, timeout, died, cap) and a snapshot whose seq goes back in as since_seq; long flights are loops of bounded waits.

Agent time is sim time: at speedup N every second spent between calls is N sim seconds with the last RC still held. So react inside the wait (then_rc, then_mode, then_speedup, applied in the tick the condition is met), schedule against the autopilot clock (sim_time until t = snapshot.t_sim_s + 15, not time, which counts from the call), and slow down to think (set_speedup(0.05): SITL cannot pause, but applies a fractional SIM_SPEEDUP live). A wait that settles in the server returns the snapshot taken in that tick, a measurement at that moment.

A manoeuvre of several steps still costs one round trip per step that way, so fly_steps runs the whole sequence inside the server. Each step applies its actions (rc, mode, speedup, params) in the tick it starts and then waits on one wait_until condition; the next step starts in the tick the previous one settled. The hop above, after arm:

fly_steps(session_id, steps=[
    {"rc": {3: 2000}, "until": "altitude", "value": 5},
    {"rc": {3: 1500}, "until": "sim_time", "value": 15, "relative": True},   # hold 15 s
    {"mode": "QLAND", "until": "disarmed", "timeout_s": 120},
])

Every step is validated before any runs. The call returns within about 85 s with each settled step's reason, start and end sim time and settle-tick snapshot, or with status running, which steps_status follows; abort_steps stops it with RC left as last set. A program cannot arm or upload a mission, since those need round trips with retries. On an idle-paced session a running program keeps the sim at full speed, whether or not a call is waiting on it, and idle applies once it ends.

Session tools: open_sitl, snapshot, get_param, set_param, set_mode, arm, disarm, set_speedup, rc_override, upload_mission, wait_until, close_sitl, list_sessions. Step tools: fly_steps, steps_status, abort_steps. Wind tools: set_wind, wind_history. Log tools: list_logs, log_summary, log_fields, log_messages, log_compare, pin_log. Lease tools: lease_instance, release_instance, kill_instance (strays only, never another holder's), list_instances.

Wind and gusts

open_sitl(wind=...), set_wind and a step's wind action set a mean wind (speed, the direction it blows from, an optional updraft) and its gusts. gusts: "dryden" runs the MIL-F-8785C model in mcardupilot.turbulence (exact update) every 0.05 sim s from the current height and airspeed and sends SITL the sum as SIM_WIND_SPD, SIM_WIND_DIR and SIM_WIND_DIR_Z, so gusts evolve through every wait; "sitl" hands turbulence to SITL's own SIM_WIND_TURB; "off" is steady. scale strengthens or weakens the gusts, and a seed repeats them exactly in another flight, since real SITL ticks on an exact 20 Hz sim clock. Snapshots show the mean wind and the gust now; wind_history returns up to two minutes of it.

Reading logs

log_summary splits a flight into phases at every flight-mode change, arm and disarm, and (in AUTO) mission item, with each phase's duration and averages: airspeed, groundspeed, throttle, lift motors (found from the log's own SERVOn_FUNCTION), pitch, peak roll, angle of attack, current, voltage, peak height. It also returns the firmware, every autopilot text with its time, and message counts. log_fields returns a time series of any message's columns with their units, thinned to fit; log_messages returns whole messages; list_logs lists the kept logs. A log is named by a session id (its live log), a kept log's name, or a path. Reads go through pymavlink's per-type index, so a 45 MB log summarizes in about a third of a second.

Kept logs are held to log_budget_gb: after every close that keeps a log, and once at server start, the oldest kept logs past the budget are deleted (the close's pruned lists them). Only the kept log directory is pruned. A log that is pinned (pin_log), written in the last ten minutes, or under a running SITL is never taken. server_info shows logs_used_gb beside the budget, and list_logs which logs are pinned.

Each phase also carries energy_wh, BAT volts times amps integrated by the trapezoid rule with the samples interpolated to the phase bounds, so the phases add up to the summary's own energy_wh.

log_compare(a, b) is for A/B tests (mcardupilot.logcompare.compare in the library). It keys each flight's phases by mode, armed state and mission item and pairs them by longest common subsequence, so an extra phase in one flight is listed on its own instead of shifting every later pair. Each pair, and the totals (duration, energy, peak height, peak current), give every number as {a, b, delta, pct}. It also diffs the parameters (last PARM value per name, with ArduPilot's own STAT_* and ground-pressure values set apart), the autopilot texts (those that differ only in their numbers grouped by a template), and the firmware banners.

A MODE record stamped at a log's first timestamp is the mode when the log file opened, not a switch at that moment: ArduPilot writes its startup records then.

Every Claude session runs its own server on the shared data directory. At start a server kills SITLs that a dead server's sessions left behind, judged by the lease: an instance whose lock it can take has no live owner.

Run it

uv run mcardupilot                                   # stdio MCP server
claude mcp add mcardupilot-dev -- uv run --directory ~/claude/mcardupilot mcardupilot   # the working tree
claude mcp add mcardupilot -- uvx --from ~/claude/mcardupilot mcardupilot                # a built copy
uv run python -m mcardupilot.leases status           # who holds which instance
uv run python -m mcardupilot.logs prune --root DIR   # dry run: oldest logs past the budget

uv run --directory runs the editable install, so a restarted server has every edit. uvx --from installs a copy, rebuilt whenever pyproject.toml or anything under src/ changes (cache-keys). mcardupilot is not on PyPI, so plain uvx mcardupilot finds nothing until it is published.

Settings

Environment variables, or a .env in the working directory.

Variable Default Meaning
MCARDUPILOT_ARDUPILOT ~/ardupilot ArduPilot tree (read and run only)
MCARDUPILOT_BINARY <ardupilot>/build/sitl/bin/arduplane SITL binary
MCARDUPILOT_DATA_DIR ~/.local/share/mcardupilot leases/, sessions/, jobs/, kept logs
MCARDUPILOT_INSTANCE_POOL 40-59 "40-59" or "40,41,45-47"; 0, 14 and 15 are refused
MCARDUPILOT_WAIT_CAP_S 85 longest any waiting tool blocks
MCARDUPILOT_SESSION_IDLE_S 1800 idle sessions close after this
MCARDUPILOT_STALL_S 30 a session's SITL whose clock stops this long is dead
MCARDUPILOT_LOG_BUDGET_GB 20 kept logs are pruned past this
MCARDUPILOT_TRANSPORT stdio stdio or http
MCARDUPILOT_HTTP_HOST / _PORT 127.0.0.1 / 8374 for http

Instance 0 belongs to autotest.py, and 14 and 15 land on the desktop containers' VNC ports, so the pool validator refuses them.

Library

from mcardupilot.sitl import Sitl

with Sitl(frame="quadplane", defaults=[".../default_params/quadplane.parm"], speedup=10) as s:
    s.arm_on_pad("QLOITER")     # leased a free pool instance, launched, waited for home
    s.rc[2] = 1800
    s.wait(6)                   # sim seconds, on the autopilot's clock
    print(s.height(), s.provenance["ardupilot_sha"])
# SITL's process group is killed, its ports are free, the lease is released

instance=None (the default) takes the first pool instance whose lock is free and whose ports (MAVLink TCP, the JSON backend, and a bridge's own) nothing else holds; instance=42 asks for one and raises InstanceBusy naming the holder. A SITL that exits or PANICs raises SitlDied after being killed. A script that dies without close() still kills its SITL at exit; one that is SIGKILLed leaves <run_root>/<n>/sitl.pid, which mcardupilot.sitl.kill_instance reads.

mcardupilot.mission uploads items given in metres from home (nav, do, box_mission), retrying while ArduPilot ignores mission traffic after boot, and reads a mission back. keep_logs="last" with log_dest= moves a flight's BIN out of the instance directory at close(); mcardupilot.logs.prune deletes the oldest logs past log_budget_gb, never one that is pinned (logs.pin), written in the last ten minutes, or under a SITL that is still running.

A subclass sets its own state, then calls start(), and overrides tick(kind, msg) to drive time-varying inputs from every pumped message. A Bridge (see sitl.py) plugs in an external physics process on SITL's JSON backend.

Development

uv run pytest -q                 # about 20 s on 8 workers, against tests/fake_autopilot.py
uv run pytest -m sitl            # real hops on a stock arduplane (MCARDUPILOT_STOCK_ARDUPILOT)
uvx ruff check . && uvx ruff format --check .

Metadata

Release files for mcardupilot 2026.10.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcardupilot 2026.10.3
File Size Uploaded
mcardupilot-2026.10.3.tar.gz 71.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcardupilot 2026.10.3
File Interpreter ABI Platform
mcardupilot-2026.10.3-py3-none-any.whl Python 3 none any Details

Total release size: 157.4 kB

Release files / mcardupilot-2026.10.3.tar.gz

Download URL mcardupilot-2026.10.3.tar.gz
Size 71.6 kB
Tags Source
SHA-256 checksum
How to use checksums
6acf2666ae42a08f8e914eec630470ab033e147324d65f9e0d90b8eb4545a291
BLAKE2b-256 checksum
How to use checksums
dc126e9fc2bd763142546c63949ee70fb075374c342f3d1972fbc9e2824ce185
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / mcardupilot-2026.10.3-py3-none-any.whl

Download URL mcardupilot-2026.10.3-py3-none-any.whl
Size 85.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9860b3c73398d175b573f9a73dbcfd93f90d4e3778284ab770c2120ce5ff86f4
BLAKE2b-256 checksum
How to use checksums
fac4c9092fd24d14d48d9d907b0f54060c15615814ad360d4bd823f4c067ac41
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page