Metadata-Version: 2.4
Name: mcardupilot
Version: 2026.10.3
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-Python: >=3.12
Project-URL: Documentation, https://mcardupilot.warehack.ing
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`.
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

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

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

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