Metadata-Version: 2.4
Name: mcardupilot
Version: 2026.10.8
Summary: ArduPilot SITL instances, flights and logs for LLM agents
Keywords: ardupilot,sitl,mavlink,mcp,fastmcp
Author: Ryan Malloy
Author-email: Ryan Malloy <ryan@supported.systems>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Scientific/Engineering
Requires-Dist: fastmcp>=4,<5
Requires-Dist: pymavlink>=2.4.50
Requires-Dist: pydantic>=2
Requires-Dist: pydantic-settings>=2.15.0
Requires-Dist: pillow>=12.3.0
Requires-Dist: fastmcp-feedback[instrumentation]>=2026.10.4 ; extra == 'instrumentation'
Requires-Python: >=3.12
Project-URL: Documentation, https://mcardupilot.warehack.ing
Provides-Extra: instrumentation
Description-Content-Type: text/markdown

# 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](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/](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`.
Build tools: `build_sitl`, `build_status`, `cancel_build`, `list_builds`.
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`.

## Prompts

Tools say what each call does; the server's MCP prompts say how to do a whole job well, with the
lessons from flying SITL through these tools written in: keep idle pacing on (the default), react inside
a wait with `then_rc`, time holds from a settle, end a QLOITER climb a metre early, read the log
before quoting a number, close the session. Every flight prompt ends by asking for a plain
answer, the evidence from the log, and a recipe that flies the same flight again.

- `hop` flies a hover hop to a height, holds it and lands, in optional seeded wind.
- `ab_test` flies one manoeuvre with two parameter sets, or two builds via `binary_a` and
  `binary_b`, in the same seeded air, and judges the difference against the spread.
- `postmortem` arrives with a log's phases and autopilot texts already summarized (repeats
  folded), then sends the agent to `log_fields` for the moments that matter.
- `preflight` arrives with the workstation's live state: binary and staleness, leases and
  strays, kept logs against the budget, build Python. It changes nothing.

`open_sitl` takes `binary` (a SITL executable) or `tree` (an ArduPilot tree, flying its
`build/sitl/bin/arduplane`), which is what lets `ab_test` compare builds. Over HTTP only
binaries inside the configured trees run.

## Presets

A project that flies its own airframe repeats the same long `open_sitl` call. A preset is
that call's arguments in a JSON file shipped beside the project's sim files, with paths
relative to the preset itself:

```json
{
  "description": "Hover hop on the keel model with the harness parameters",
  "model": "quadplane-elevon:../models/keel.json",
  "params": ["../params/common.parm", "../params/airframe.parm"],
  "set": {"SERVO_AUTO_TRIM": 0},
  "rc": {"5": 1425}
}
```

Put the directory in `MCARDUPILOT_PRESET_DIRS`, and `open_sitl(preset="hover")` boots with
it. `list_presets` shows what is there. Arguments given alongside override the preset's,
and `set`, `rc` and `wind` merge key by key. Over HTTP presets open by name only.

## Recipes and repro

Every session opened with `open_sitl` writes a recipe beside its kept log when it closes:
`<stem>.recipe.json`, holding the open arguments as they took effect (a preset merged in,
the parameter files SITL actually loaded, the whole overlay, the binary that flew) and every
call that acted on the aircraft, in order, with its outcome. `list_logs` marks the logs that
have one, and the log budget prunes it with its log.

`repro(log)` flies a recipe again through the same tools: it reopens the session, replays the
calls (each wait and step program runs to its end, so a slower machine replays the flight,
not the clock), closes, and returns `log_compare` of the two flights. Pass `binary` or
`tree` to fly a new build against the old flight. It is the same recipe, not the same
bytes, since commands land in whatever sim tick they reach: a gusty hover flown twice on
real ArduPilot agreed within 0.4% on every flying phase and 0.3% on energy.

## Exact flights

SITL computes the same flight every time: two idle boots at speedups 10 and 4, side by side,
agree on every IMU sample. Flights differ because an input sent over MAVLink lands on
whichever main-loop tick follows its arrival, a wall-clock matter (and a Lua script does no
better, since SITL runs scripting on a thread that sleeps on the wall clock). So
`open_sitl(exact=true)` pins SITL's clock, logs from boot and sends no RC overrides, taking
its parameters and wind at boot; `fly_exact(session_id, items)` then uploads the mission
behind a `NAV_DELAY` that waits, disarmed, for a whole sim second, and a `DO_AUX_FUNCTION`
that arms from inside the mission. Nothing steers it after that, and the steering tools say
so. A `repro` of the kept log is a copy: its `identical` checks the two logs record by record
from the anchor (truth, sensors, estimator, outputs, battery) and names the first
difference. On stock ArduPilot, in SITL's own turbulence, a takeoff, loiter and landing
replayed identical across more than 60,000 IMU samples.

## Sets of flights

One replay cannot tell a change from noise: SITL flights are not bit-for-bit repeatable, and a
flight that crosses a hard threshold can come out either way. `batch_compare(a=[...], b=[...])`
compares two sets of any dataflash logs (a harness's included), phase by phase and in total:
both distributions per metric, the gap and its size in pooled standard deviations, Welch's p,
and q, that p adjusted for the dozens of tests one comparison runs (Benjamini-Hochberg).
`shown` lists what clears q < 0.05. Measures add numbers of your own (a log field reduced over a
window, a phase metric, an autopilot text), and with an op and value they become yes/no
outcomes, compared by rate with Fisher's exact test.

`batch_fly(log, n)` flies a kept log's recipe n times on side A and n on side B, interleaved, and
compares them. With no B given it flies the same binary on both sides: a null control. On real
ArduPilot, three gusty hovers a side on the identical binary showed nothing across 87 tests,
with hover current agreeing within 0.34%; the control is what any real gap has to beat.
`repro` flags a flying phase that differs far more than the others, the mark of a flight near a
threshold, and points to `batch_fly`.

## Video

A session can be watched live, and any flight can be rendered to an MP4 afterwards. Both
draw the same picture: an FPV nose camera with a HUD (height, airspeed, the true wind and its
gust, mode, throttle, lift, current, autopilot messages), a chase camera from behind, or the
FPV view with the chase view inset.

`video_feed(session_id)` starts a feed and returns its links: `vlc http://127.0.0.1:<8400 +
instance>/video.ts` for VLC or mpv, and a page with an MJPEG stream for a browser. The pose is
SITL's own simulated state, from the FlightGear packets it sends with `--enable-fgview` (which
every session now launches with; it costs SITL about 4% more CPU). While anyone watches, the
session runs no faster than real time, so the flight looks the way it would fly; idle pacing
still holds it between the agent's calls, which the HUD shows as HOLDING.

`render_video(log)` renders a dataflash log, from SITL's own records of where the aircraft
was (`SIM`, `SIM2`) and the wind it flew (its logged `SIM_WIND_*` changes), so the video plays
at the speed the aircraft flew whatever speedup the session ran at. A kept log's video lands
beside it as `<stem>.<layout>.mp4`, and the log budget counts and prunes it with the log; any
other log renders into `<data_dir>/videos`. `open_sitl(record_video="pip")` does this at close.
`video_frame` returns one frame as an image, live or from a log, so the agent can look too.
Encoding uses NVENC when the GPU has it, else libx264; ffmpeg must be on `PATH`.

`render_film(log, camera="chase"|"ground"|"fpv")` renders the same log in Blender as a lit 3D scene
(sky, sun, shadow, a runway along the home heading) to `<stem>.film-<camera>.mp4`. The server
computes every pose and camera position; Blender only builds the scene and renders frames, each
of which goes to ffmpeg and is deleted as soon as it is complete. An airframe description (JSON: STL parts with colours, and rotors that each spin with their own logged output) draws your own aircraft; a preset names it with `"airframe"`, the recipe records it, and every film of that preset's flights wears it. `MCARDUPILOT_FILM_AIRFRAME` sets a default, and without any the film draws a simple quadplane with spinning props.
`MCARDUPILOT_FILM_BLENDER` is `blender` on `PATH`, a path, or `docker:<image>`.

## 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.

## Building SITL

`build_sitl` runs `./waf configure --board sitl` (only when needed) and `./waf plane` (or another
vehicle) as a background job; `build_status` follows it with bounded waits and waf's `[n/N]`
progress, `cancel_build` stops it, and a finished build reports the binary and its provenance.
Only trees named in `MCARDUPILOT_BUILD_TREES` are built, plus the default tree while its git
checkout is clean, so a patched tree other runs fly is never rebuilt by accident. waf runs under
`MCARDUPILOT_BUILD_PYTHON`, else `~/venv-ardupilot`, else `python3` outside this server's venv,
and a tree copied from another keeps a waf lock naming the original, which the build detects and
rebinds instead of building into it.

Container mode builds explicit inputs instead of a working tree: give `image` (by digest,
`name@sha256:...`; a bare tag is refused) and `commit`, optionally `patches` in order, and `tree`
names the source repository, which is only read. A fresh local clone is checked out at the
commit, its submodules cloned from the source's own (so nothing needs the network), the patches
applied after `git apply --check`, and the build runs in the image at one fixed path with no
network, `SOURCE_DATE_EPOCH` from the commit and ccache off. The output lands in
`<data_dir>/builds/<build_id>/` in tree layout with a `manifest.json` (inputs, toolchain
versions, binary sha256 and size, build time); `build_id` is a hash of the inputs, so asking
again answers from the finished build. `reproduce=true` builds a second time from a fresh
checkout and records whether the binary came out byte for byte the same, and if not, which ELF
sections differ. `open_sitl(tree=<build dir>)` flies it, and its provenance then carries the
build id, image, commit and patch hashes from the manifest.

## Reading logs

`log_summary` splits a flight into phases at every flight-mode change, arm and disarm, takeoff
and touchdown (ArduPlane's `STAT.isFlying`, so a hover's averages leave out ground idle), 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

```bash
uv run mcardupilot                                   # stdio MCP server
claude mcp add mcardupilot-dev -- uv run --directory /path/to/mcardupilot mcardupilot   # the working tree
claude mcp add mcardupilot -- uvx --from /path/to/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`).
From PyPI it is just `claude mcp add mcardupilot -- uvx mcardupilot`.

## 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_PRESET_DIRS` | empty | directories of `open_sitl` presets, separated by `:` or `,`; `<data_dir>/presets` is searched last |
| `MCARDUPILOT_BUILD_TREES` | empty | trees `build_sitl` may build, separated by `:` or `,` |
| `MCARDUPILOT_BUILD_DEFAULT_TREE` | `1` | also build the default tree while its checkout is clean |
| `MCARDUPILOT_BUILD_PYTHON` | auto | the interpreter waf runs under |
| `MCARDUPILOT_LOG_BUDGET_GB` | `20` | kept logs are pruned past this |
| `MCARDUPILOT_VIDEO_HOST` | `127.0.0.1` | where live feeds listen; `0.0.0.0` to watch from the LAN |
| `MCARDUPILOT_VIDEO_PORT_BASE` | `8400` | a session's live feed is on this plus its instance |
| `MCARDUPILOT_VIDEO_SIZE` / `_FPS` | `960x540` / `30` | for live feeds and renders |
| `MCARDUPILOT_VIDEO_ENCODER` | `auto` | `h264_nvenc`, `libx264`, or the first that works |
| `MCARDUPILOT_VIDEO_FGVIEW` | `1` | launch SITL with `--enable-fgview` for live pose |
| `MCARDUPILOT_FILM_BLENDER` | `blender` | Blender 4.2+ for `render_film`: a command, a path, or `docker:<image>` |
| `MCARDUPILOT_FILM_SIZE` / `_ENGINE` | `1280x720` / `eevee` | a film's size, and `eevee` or `cycles` |
| `MCARDUPILOT_FILM_AIRFRAME` | unset | an airframe description for films (parts, colours, rotors); a log's preset can name its own |
| `MCARDUPILOT_FILM_MESH` | unset | one STL in one colour, when no description is given |
| `MCARDUPILOT_FILM_AXES` / `_SCALE` / `_CG` / `_FACES` | `x,y,z` / `1` / centre / `60000` | how the mesh sits: forward, left, up; units to metres; CG in mesh units; decimation |
| `MCARDUPILOT_CALLS_DB` | unset | record tool calls to this SQLite file (needs the `instrumentation` extra); unset records nothing |
| `MCARDUPILOT_CALLS_MODE` | `meta` | `meta` stores timing, outcome and sizes only; `full` adds redacted arguments and results |
| `MCARDUPILOT_CALLS_RETENTION_DAYS` | `30` | older call records are deleted |
| `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.

## Call records

With `uvx 'mcardupilot[instrumentation]'` and `MCARDUPILOT_CALLS_DB=~/.local/share/mcardupilot/calls.db`,
every tool call becomes a row in that local SQLite file through
[fastmcp-feedback](https://pypi.org/project/fastmcp-feedback/): the tool, its timing, its outcome
(ok, error, or a soft error such as a build that failed or a wait whose SITL died), redacted error
text and payload sizes. No arguments or results are stored in the default `meta` mode, and nothing
leaves the machine. Builds, renders and sessions also record `job.started` and `job.finished`
events, keyed by a uuid that the starting call's row carries in `extra.job`, so a start call that
returned ok for work that failed later is one join away. Without the variable nothing is recorded
and no file is created. Several server processes can share one database.

## Library

```python
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.

## Credits

Every flight here is flown by [ArduPilot](https://ardupilot.org), the open-source autopilot, through its software-in-the-loop simulator. A film's title card says "Powered by ArduPilot" with the project's logo, used unchanged under CC BY-SA 3.0 (`src/mcardupilot/video/assets/NOTICE.md`). ArduPilot is a registered trademark of Software in the Public Interest, Inc. The credit does not imply that the ArduPilot project endorses or supports mcardupilot.

## Development

```bash
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 .
```
