mcardupilot 2026.10.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. mcardupilot-2026.10.3/LICENSE +21 -0
  2. mcardupilot-2026.10.3/PKG-INFO +214 -0
  3. mcardupilot-2026.10.3/README.md +190 -0
  4. mcardupilot-2026.10.3/pyproject.toml +80 -0
  5. mcardupilot-2026.10.3/src/mcardupilot/__init__.py +8 -0
  6. mcardupilot-2026.10.3/src/mcardupilot/config.py +151 -0
  7. mcardupilot-2026.10.3/src/mcardupilot/errors.py +23 -0
  8. mcardupilot-2026.10.3/src/mcardupilot/frames.py +48 -0
  9. mcardupilot-2026.10.3/src/mcardupilot/leases.py +234 -0
  10. mcardupilot-2026.10.3/src/mcardupilot/logcompare.py +188 -0
  11. mcardupilot-2026.10.3/src/mcardupilot/logread.py +419 -0
  12. mcardupilot-2026.10.3/src/mcardupilot/logs.py +285 -0
  13. mcardupilot-2026.10.3/src/mcardupilot/mission.py +154 -0
  14. mcardupilot-2026.10.3/src/mcardupilot/models.py +277 -0
  15. mcardupilot-2026.10.3/src/mcardupilot/procs.py +234 -0
  16. mcardupilot-2026.10.3/src/mcardupilot/provenance.py +63 -0
  17. mcardupilot-2026.10.3/src/mcardupilot/server.py +60 -0
  18. mcardupilot-2026.10.3/src/mcardupilot/sessions.py +973 -0
  19. mcardupilot-2026.10.3/src/mcardupilot/sitl.py +615 -0
  20. mcardupilot-2026.10.3/src/mcardupilot/state.py +50 -0
  21. mcardupilot-2026.10.3/src/mcardupilot/steps.py +302 -0
  22. mcardupilot-2026.10.3/src/mcardupilot/tools/__init__.py +23 -0
  23. mcardupilot-2026.10.3/src/mcardupilot/tools/_common.py +43 -0
  24. mcardupilot-2026.10.3/src/mcardupilot/tools/compare_tools.py +47 -0
  25. mcardupilot-2026.10.3/src/mcardupilot/tools/info.py +43 -0
  26. mcardupilot-2026.10.3/src/mcardupilot/tools/lease_tools.py +161 -0
  27. mcardupilot-2026.10.3/src/mcardupilot/tools/log_tools.py +170 -0
  28. mcardupilot-2026.10.3/src/mcardupilot/tools/session_tools.py +472 -0
  29. mcardupilot-2026.10.3/src/mcardupilot/tools/steps_tools.py +130 -0
  30. mcardupilot-2026.10.3/src/mcardupilot/tools/wind_tools.py +43 -0
  31. mcardupilot-2026.10.3/src/mcardupilot/turbulence.py +100 -0
  32. mcardupilot-2026.10.3/src/mcardupilot/wind.py +157 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Malloy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcardupilot
3
+ Version: 2026.10.3
4
+ Summary: ArduPilot SITL instances, flights and logs for LLM agents
5
+ Keywords: ardupilot,sitl,mavlink,mcp,fastmcp
6
+ Author: Ryan Malloy
7
+ Author-email: Ryan Malloy <ryan@supported.systems>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Dist: fastmcp>=4,<5
18
+ Requires-Dist: pymavlink>=2.4.50
19
+ Requires-Dist: pydantic>=2
20
+ Requires-Dist: pydantic-settings>=2.15.0
21
+ Requires-Python: >=3.12
22
+ Project-URL: Documentation, https://mcardupilot.warehack.ing
23
+ Description-Content-Type: text/markdown
24
+
25
+ # mcardupilot
26
+
27
+ ArduPilot SITL instances, flights and dataflash logs for LLM agents, as a Python library with
28
+ an MCP server on top of it.
29
+
30
+ Several sessions on one workstation fly SITL at the same time. Each SITL instance number picks
31
+ its ports (MAVLink TCP `5760 + 10n`, the JSON backend `9002 + 10n`), so mcardupilot hands out
32
+ instances from a shared pool through flock leases. A lease names its holder, and the kernel
33
+ drops it when the holder exits, so a crashed run never strands an instance.
34
+
35
+ Status: phases 0 to 4 of [docs/PLAN.md](docs/PLAN.md): settings, leases, the `Sitl` library
36
+ class, missions, log housekeeping, the MCP flight tools, and log reading.
37
+
38
+ ## Documentation
39
+
40
+ The documentation site lives in [docs-site/](docs-site/) (Astro and Starlight), to be published
41
+ at https://mcardupilot.warehack.ing: a tutorial, how-to guides, a tool and settings reference
42
+ generated from the code (`make -C docs-site data`), and explanations of the design.
43
+
44
+ ## Flying through the tools
45
+
46
+ `open_sitl` starts a SITL on a leased instance and returns a session id. A thread per session
47
+ owns the MAVLink link and keeps it alive between calls (GCS heartbeat and RC overrides, paced on
48
+ sim time), so the failsafes stay quiet while the agent thinks. A hover hop:
49
+
50
+ ```
51
+ open_sitl(model="quadplane") # frame defaults from the tree's vehicleinfo.json
52
+ arm(session_id, mode="QLOITER")
53
+ rc_override(session_id, channels={3: 2000}) # climb
54
+ wait_until(session_id, "altitude", 5, then_rc={3: 1500}) # hold at 5 m, applied in the same tick
55
+ set_mode(session_id, "QLAND")
56
+ wait_until(session_id, "disarmed", timeout_s=120)
57
+ close_sitl(session_id) # SITL killed, instance freed, BIN kept
58
+ ```
59
+
60
+ `wait_until` returns within about 85 s with a reason (`met`, `timeout`, `died`, `cap`) and a
61
+ snapshot whose `seq` goes back in as `since_seq`; long flights are loops of bounded waits.
62
+
63
+ Agent time is sim time: at speedup N every second spent between calls is N sim seconds with the
64
+ last RC still held. So react inside the wait (`then_rc`, `then_mode`, `then_speedup`, applied in
65
+ the tick the condition is met), schedule against the autopilot clock (`sim_time` until
66
+ `t = snapshot.t_sim_s + 15`, not `time`, which counts from the call), and slow down to think
67
+ (`set_speedup(0.05)`: SITL cannot pause, but applies a fractional `SIM_SPEEDUP` live). A wait
68
+ that settles in the server returns the snapshot taken in that tick, a measurement at that moment.
69
+
70
+ A manoeuvre of several steps still costs one round trip per step that way, so `fly_steps` runs
71
+ the whole sequence inside the server. Each step applies its actions (`rc`, `mode`, `speedup`,
72
+ `params`) in the tick it starts and then waits on one `wait_until` condition; the next step
73
+ starts in the tick the previous one settled. The hop above, after `arm`:
74
+
75
+ ```
76
+ fly_steps(session_id, steps=[
77
+ {"rc": {3: 2000}, "until": "altitude", "value": 5},
78
+ {"rc": {3: 1500}, "until": "sim_time", "value": 15, "relative": True}, # hold 15 s
79
+ {"mode": "QLAND", "until": "disarmed", "timeout_s": 120},
80
+ ])
81
+ ```
82
+
83
+ Every step is validated before any runs. The call returns within about 85 s with each settled
84
+ step's reason, start and end sim time and settle-tick snapshot, or with status `running`, which
85
+ `steps_status` follows; `abort_steps` stops it with RC left as last set. A program cannot arm or
86
+ upload a mission, since those need round trips with retries. On an idle-paced session a running
87
+ program keeps the sim at full speed, whether or not a call is waiting on it, and idle applies
88
+ once it ends.
89
+
90
+ Session tools: `open_sitl`, `snapshot`, `get_param`, `set_param`, `set_mode`, `arm`, `disarm`,
91
+ `set_speedup`, `rc_override`, `upload_mission`, `wait_until`, `close_sitl`, `list_sessions`.
92
+ Step tools: `fly_steps`, `steps_status`, `abort_steps`.
93
+ Wind tools: `set_wind`, `wind_history`.
94
+ Log tools: `list_logs`, `log_summary`, `log_fields`, `log_messages`, `log_compare`, `pin_log`.
95
+ Lease tools: `lease_instance`, `release_instance`,
96
+ `kill_instance` (strays only, never another holder's), `list_instances`.
97
+
98
+ ## Wind and gusts
99
+
100
+ `open_sitl(wind=...)`, `set_wind` and a step's `wind` action set a mean wind (speed, the
101
+ direction it blows from, an optional updraft) and its gusts. `gusts: "dryden"` runs the
102
+ MIL-F-8785C model in `mcardupilot.turbulence` (exact update) every 0.05 sim s from the current
103
+ height and airspeed and sends SITL the sum as `SIM_WIND_SPD`, `SIM_WIND_DIR` and
104
+ `SIM_WIND_DIR_Z`, so gusts evolve through every wait; `"sitl"` hands turbulence to SITL's own
105
+ `SIM_WIND_TURB`; `"off"` is steady. `scale` strengthens or weakens the gusts, and a `seed`
106
+ repeats them exactly in another flight, since real SITL ticks on an exact 20 Hz sim clock.
107
+ Snapshots show the mean wind and the gust now; `wind_history` returns up to two minutes of it.
108
+
109
+ ## Reading logs
110
+
111
+ `log_summary` splits a flight into phases at every flight-mode change, arm and disarm, and (in
112
+ AUTO) mission item, with each phase's duration and averages: airspeed, groundspeed, throttle,
113
+ lift motors (found from the log's own `SERVOn_FUNCTION`), pitch, peak roll, angle of attack,
114
+ current, voltage, peak height. It also returns the firmware, every autopilot text with its
115
+ time, and message counts. `log_fields` returns a time series of any message's columns with
116
+ their units, thinned to fit; `log_messages` returns whole messages; `list_logs` lists the kept
117
+ logs. A log is named by a session id (its live log), a kept log's name, or a path. Reads go
118
+ through pymavlink's per-type index, so a 45 MB log summarizes in about a third of a second.
119
+
120
+ Kept logs are held to `log_budget_gb`: after every close that keeps a log, and once at server
121
+ start, the oldest kept logs past the budget are deleted (the close's `pruned` lists them).
122
+ Only the kept log directory is pruned. A log that is pinned (`pin_log`), written in the last ten
123
+ minutes, or under a running SITL is never taken. `server_info` shows `logs_used_gb` beside the
124
+ budget, and `list_logs` which logs are pinned.
125
+
126
+ Each phase also carries `energy_wh`, BAT volts times amps integrated by the trapezoid rule
127
+ with the samples interpolated to the phase bounds, so the phases add up to the summary's own
128
+ `energy_wh`.
129
+
130
+ `log_compare(a, b)` is for A/B tests (`mcardupilot.logcompare.compare` in the library). It keys
131
+ each flight's phases by mode, armed state and mission item and pairs them by longest common
132
+ subsequence, so an extra phase in one flight is listed on its own instead of shifting every
133
+ later pair. Each pair, and the totals (duration, energy, peak height, peak current), give every
134
+ number as `{a, b, delta, pct}`. It also diffs the parameters (last PARM value per name, with
135
+ ArduPilot's own `STAT_*` and ground-pressure values set apart), the autopilot texts (those that
136
+ differ only in their numbers grouped by a template), and the firmware banners.
137
+
138
+ A MODE record stamped at a log's first timestamp is the mode when the log file opened, not a
139
+ switch at that moment: ArduPilot writes its startup records then.
140
+
141
+ Every Claude session runs its own server on the shared data directory. At start a server kills
142
+ SITLs that a dead server's sessions left behind, judged by the lease: an instance whose lock it
143
+ can take has no live owner.
144
+
145
+ ## Run it
146
+
147
+ ```bash
148
+ uv run mcardupilot # stdio MCP server
149
+ claude mcp add mcardupilot-dev -- uv run --directory ~/claude/mcardupilot mcardupilot # the working tree
150
+ claude mcp add mcardupilot -- uvx --from ~/claude/mcardupilot mcardupilot # a built copy
151
+ uv run python -m mcardupilot.leases status # who holds which instance
152
+ uv run python -m mcardupilot.logs prune --root DIR # dry run: oldest logs past the budget
153
+ ```
154
+
155
+ `uv run --directory` runs the editable install, so a restarted server has every edit. `uvx --from`
156
+ installs a copy, rebuilt whenever `pyproject.toml` or anything under `src/` changes (`cache-keys`).
157
+ mcardupilot is not on PyPI, so plain `uvx mcardupilot` finds nothing until it is published.
158
+
159
+ ## Settings
160
+
161
+ Environment variables, or a `.env` in the working directory.
162
+
163
+ | Variable | Default | Meaning |
164
+ | --- | --- | --- |
165
+ | `MCARDUPILOT_ARDUPILOT` | `~/ardupilot` | ArduPilot tree (read and run only) |
166
+ | `MCARDUPILOT_BINARY` | `<ardupilot>/build/sitl/bin/arduplane` | SITL binary |
167
+ | `MCARDUPILOT_DATA_DIR` | `~/.local/share/mcardupilot` | `leases/`, `sessions/`, `jobs/`, kept logs |
168
+ | `MCARDUPILOT_INSTANCE_POOL` | `40-59` | `"40-59"` or `"40,41,45-47"`; 0, 14 and 15 are refused |
169
+ | `MCARDUPILOT_WAIT_CAP_S` | `85` | longest any waiting tool blocks |
170
+ | `MCARDUPILOT_SESSION_IDLE_S` | `1800` | idle sessions close after this |
171
+ | `MCARDUPILOT_STALL_S` | `30` | a session's SITL whose clock stops this long is dead |
172
+ | `MCARDUPILOT_LOG_BUDGET_GB` | `20` | kept logs are pruned past this |
173
+ | `MCARDUPILOT_TRANSPORT` | `stdio` | `stdio` or `http` |
174
+ | `MCARDUPILOT_HTTP_HOST` / `_PORT` | `127.0.0.1` / `8374` | for `http` |
175
+
176
+ Instance 0 belongs to `autotest.py`, and 14 and 15 land on the desktop containers' VNC ports,
177
+ so the pool validator refuses them.
178
+
179
+ ## Library
180
+
181
+ ```python
182
+ from mcardupilot.sitl import Sitl
183
+
184
+ with Sitl(frame="quadplane", defaults=[".../default_params/quadplane.parm"], speedup=10) as s:
185
+ s.arm_on_pad("QLOITER") # leased a free pool instance, launched, waited for home
186
+ s.rc[2] = 1800
187
+ s.wait(6) # sim seconds, on the autopilot's clock
188
+ print(s.height(), s.provenance["ardupilot_sha"])
189
+ # SITL's process group is killed, its ports are free, the lease is released
190
+ ```
191
+
192
+ `instance=None` (the default) takes the first pool instance whose lock is free and whose ports
193
+ (MAVLink TCP, the JSON backend, and a bridge's own) nothing else holds; `instance=42` asks for one
194
+ and raises `InstanceBusy` naming the holder. A SITL that exits or PANICs raises `SitlDied` after
195
+ being killed. A script that dies without `close()` still kills its SITL at exit; one that is
196
+ SIGKILLed leaves `<run_root>/<n>/sitl.pid`, which `mcardupilot.sitl.kill_instance` reads.
197
+
198
+ `mcardupilot.mission` uploads items given in metres from home (`nav`, `do`, `box_mission`),
199
+ retrying while ArduPilot ignores mission traffic after boot, and reads a mission back.
200
+ `keep_logs="last"` with `log_dest=` moves a flight's BIN out of the instance directory at
201
+ `close()`; `mcardupilot.logs.prune` deletes the oldest logs past `log_budget_gb`, never one that is
202
+ pinned (`logs.pin`), written in the last ten minutes, or under a SITL that is still running.
203
+
204
+ A subclass sets its own state, then calls `start()`, and overrides `tick(kind, msg)` to drive
205
+ time-varying inputs from every pumped message. A `Bridge` (see `sitl.py`) plugs in an external
206
+ physics process on SITL's JSON backend.
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ uv run pytest -q # about 20 s on 8 workers, against tests/fake_autopilot.py
212
+ uv run pytest -m sitl # real hops on a stock arduplane (MCARDUPILOT_STOCK_ARDUPILOT)
213
+ uvx ruff check . && uvx ruff format --check .
214
+ ```
@@ -0,0 +1,190 @@
1
+ # mcardupilot
2
+
3
+ ArduPilot SITL instances, flights and dataflash logs for LLM agents, as a Python library with
4
+ an MCP server on top of it.
5
+
6
+ Several sessions on one workstation fly SITL at the same time. Each SITL instance number picks
7
+ its ports (MAVLink TCP `5760 + 10n`, the JSON backend `9002 + 10n`), so mcardupilot hands out
8
+ instances from a shared pool through flock leases. A lease names its holder, and the kernel
9
+ drops it when the holder exits, so a crashed run never strands an instance.
10
+
11
+ Status: phases 0 to 4 of [docs/PLAN.md](docs/PLAN.md): settings, leases, the `Sitl` library
12
+ class, missions, log housekeeping, the MCP flight tools, and log reading.
13
+
14
+ ## Documentation
15
+
16
+ The documentation site lives in [docs-site/](docs-site/) (Astro and Starlight), to be published
17
+ at https://mcardupilot.warehack.ing: a tutorial, how-to guides, a tool and settings reference
18
+ generated from the code (`make -C docs-site data`), and explanations of the design.
19
+
20
+ ## Flying through the tools
21
+
22
+ `open_sitl` starts a SITL on a leased instance and returns a session id. A thread per session
23
+ owns the MAVLink link and keeps it alive between calls (GCS heartbeat and RC overrides, paced on
24
+ sim time), so the failsafes stay quiet while the agent thinks. A hover hop:
25
+
26
+ ```
27
+ open_sitl(model="quadplane") # frame defaults from the tree's vehicleinfo.json
28
+ arm(session_id, mode="QLOITER")
29
+ rc_override(session_id, channels={3: 2000}) # climb
30
+ wait_until(session_id, "altitude", 5, then_rc={3: 1500}) # hold at 5 m, applied in the same tick
31
+ set_mode(session_id, "QLAND")
32
+ wait_until(session_id, "disarmed", timeout_s=120)
33
+ close_sitl(session_id) # SITL killed, instance freed, BIN kept
34
+ ```
35
+
36
+ `wait_until` returns within about 85 s with a reason (`met`, `timeout`, `died`, `cap`) and a
37
+ snapshot whose `seq` goes back in as `since_seq`; long flights are loops of bounded waits.
38
+
39
+ Agent time is sim time: at speedup N every second spent between calls is N sim seconds with the
40
+ last RC still held. So react inside the wait (`then_rc`, `then_mode`, `then_speedup`, applied in
41
+ the tick the condition is met), schedule against the autopilot clock (`sim_time` until
42
+ `t = snapshot.t_sim_s + 15`, not `time`, which counts from the call), and slow down to think
43
+ (`set_speedup(0.05)`: SITL cannot pause, but applies a fractional `SIM_SPEEDUP` live). A wait
44
+ that settles in the server returns the snapshot taken in that tick, a measurement at that moment.
45
+
46
+ A manoeuvre of several steps still costs one round trip per step that way, so `fly_steps` runs
47
+ the whole sequence inside the server. Each step applies its actions (`rc`, `mode`, `speedup`,
48
+ `params`) in the tick it starts and then waits on one `wait_until` condition; the next step
49
+ starts in the tick the previous one settled. The hop above, after `arm`:
50
+
51
+ ```
52
+ fly_steps(session_id, steps=[
53
+ {"rc": {3: 2000}, "until": "altitude", "value": 5},
54
+ {"rc": {3: 1500}, "until": "sim_time", "value": 15, "relative": True}, # hold 15 s
55
+ {"mode": "QLAND", "until": "disarmed", "timeout_s": 120},
56
+ ])
57
+ ```
58
+
59
+ Every step is validated before any runs. The call returns within about 85 s with each settled
60
+ step's reason, start and end sim time and settle-tick snapshot, or with status `running`, which
61
+ `steps_status` follows; `abort_steps` stops it with RC left as last set. A program cannot arm or
62
+ upload a mission, since those need round trips with retries. On an idle-paced session a running
63
+ program keeps the sim at full speed, whether or not a call is waiting on it, and idle applies
64
+ once it ends.
65
+
66
+ Session tools: `open_sitl`, `snapshot`, `get_param`, `set_param`, `set_mode`, `arm`, `disarm`,
67
+ `set_speedup`, `rc_override`, `upload_mission`, `wait_until`, `close_sitl`, `list_sessions`.
68
+ Step tools: `fly_steps`, `steps_status`, `abort_steps`.
69
+ Wind tools: `set_wind`, `wind_history`.
70
+ Log tools: `list_logs`, `log_summary`, `log_fields`, `log_messages`, `log_compare`, `pin_log`.
71
+ Lease tools: `lease_instance`, `release_instance`,
72
+ `kill_instance` (strays only, never another holder's), `list_instances`.
73
+
74
+ ## Wind and gusts
75
+
76
+ `open_sitl(wind=...)`, `set_wind` and a step's `wind` action set a mean wind (speed, the
77
+ direction it blows from, an optional updraft) and its gusts. `gusts: "dryden"` runs the
78
+ MIL-F-8785C model in `mcardupilot.turbulence` (exact update) every 0.05 sim s from the current
79
+ height and airspeed and sends SITL the sum as `SIM_WIND_SPD`, `SIM_WIND_DIR` and
80
+ `SIM_WIND_DIR_Z`, so gusts evolve through every wait; `"sitl"` hands turbulence to SITL's own
81
+ `SIM_WIND_TURB`; `"off"` is steady. `scale` strengthens or weakens the gusts, and a `seed`
82
+ repeats them exactly in another flight, since real SITL ticks on an exact 20 Hz sim clock.
83
+ Snapshots show the mean wind and the gust now; `wind_history` returns up to two minutes of it.
84
+
85
+ ## Reading logs
86
+
87
+ `log_summary` splits a flight into phases at every flight-mode change, arm and disarm, and (in
88
+ AUTO) mission item, with each phase's duration and averages: airspeed, groundspeed, throttle,
89
+ lift motors (found from the log's own `SERVOn_FUNCTION`), pitch, peak roll, angle of attack,
90
+ current, voltage, peak height. It also returns the firmware, every autopilot text with its
91
+ time, and message counts. `log_fields` returns a time series of any message's columns with
92
+ their units, thinned to fit; `log_messages` returns whole messages; `list_logs` lists the kept
93
+ logs. A log is named by a session id (its live log), a kept log's name, or a path. Reads go
94
+ through pymavlink's per-type index, so a 45 MB log summarizes in about a third of a second.
95
+
96
+ Kept logs are held to `log_budget_gb`: after every close that keeps a log, and once at server
97
+ start, the oldest kept logs past the budget are deleted (the close's `pruned` lists them).
98
+ Only the kept log directory is pruned. A log that is pinned (`pin_log`), written in the last ten
99
+ minutes, or under a running SITL is never taken. `server_info` shows `logs_used_gb` beside the
100
+ budget, and `list_logs` which logs are pinned.
101
+
102
+ Each phase also carries `energy_wh`, BAT volts times amps integrated by the trapezoid rule
103
+ with the samples interpolated to the phase bounds, so the phases add up to the summary's own
104
+ `energy_wh`.
105
+
106
+ `log_compare(a, b)` is for A/B tests (`mcardupilot.logcompare.compare` in the library). It keys
107
+ each flight's phases by mode, armed state and mission item and pairs them by longest common
108
+ subsequence, so an extra phase in one flight is listed on its own instead of shifting every
109
+ later pair. Each pair, and the totals (duration, energy, peak height, peak current), give every
110
+ number as `{a, b, delta, pct}`. It also diffs the parameters (last PARM value per name, with
111
+ ArduPilot's own `STAT_*` and ground-pressure values set apart), the autopilot texts (those that
112
+ differ only in their numbers grouped by a template), and the firmware banners.
113
+
114
+ A MODE record stamped at a log's first timestamp is the mode when the log file opened, not a
115
+ switch at that moment: ArduPilot writes its startup records then.
116
+
117
+ Every Claude session runs its own server on the shared data directory. At start a server kills
118
+ SITLs that a dead server's sessions left behind, judged by the lease: an instance whose lock it
119
+ can take has no live owner.
120
+
121
+ ## Run it
122
+
123
+ ```bash
124
+ uv run mcardupilot # stdio MCP server
125
+ claude mcp add mcardupilot-dev -- uv run --directory ~/claude/mcardupilot mcardupilot # the working tree
126
+ claude mcp add mcardupilot -- uvx --from ~/claude/mcardupilot mcardupilot # a built copy
127
+ uv run python -m mcardupilot.leases status # who holds which instance
128
+ uv run python -m mcardupilot.logs prune --root DIR # dry run: oldest logs past the budget
129
+ ```
130
+
131
+ `uv run --directory` runs the editable install, so a restarted server has every edit. `uvx --from`
132
+ installs a copy, rebuilt whenever `pyproject.toml` or anything under `src/` changes (`cache-keys`).
133
+ mcardupilot is not on PyPI, so plain `uvx mcardupilot` finds nothing until it is published.
134
+
135
+ ## Settings
136
+
137
+ Environment variables, or a `.env` in the working directory.
138
+
139
+ | Variable | Default | Meaning |
140
+ | --- | --- | --- |
141
+ | `MCARDUPILOT_ARDUPILOT` | `~/ardupilot` | ArduPilot tree (read and run only) |
142
+ | `MCARDUPILOT_BINARY` | `<ardupilot>/build/sitl/bin/arduplane` | SITL binary |
143
+ | `MCARDUPILOT_DATA_DIR` | `~/.local/share/mcardupilot` | `leases/`, `sessions/`, `jobs/`, kept logs |
144
+ | `MCARDUPILOT_INSTANCE_POOL` | `40-59` | `"40-59"` or `"40,41,45-47"`; 0, 14 and 15 are refused |
145
+ | `MCARDUPILOT_WAIT_CAP_S` | `85` | longest any waiting tool blocks |
146
+ | `MCARDUPILOT_SESSION_IDLE_S` | `1800` | idle sessions close after this |
147
+ | `MCARDUPILOT_STALL_S` | `30` | a session's SITL whose clock stops this long is dead |
148
+ | `MCARDUPILOT_LOG_BUDGET_GB` | `20` | kept logs are pruned past this |
149
+ | `MCARDUPILOT_TRANSPORT` | `stdio` | `stdio` or `http` |
150
+ | `MCARDUPILOT_HTTP_HOST` / `_PORT` | `127.0.0.1` / `8374` | for `http` |
151
+
152
+ Instance 0 belongs to `autotest.py`, and 14 and 15 land on the desktop containers' VNC ports,
153
+ so the pool validator refuses them.
154
+
155
+ ## Library
156
+
157
+ ```python
158
+ from mcardupilot.sitl import Sitl
159
+
160
+ with Sitl(frame="quadplane", defaults=[".../default_params/quadplane.parm"], speedup=10) as s:
161
+ s.arm_on_pad("QLOITER") # leased a free pool instance, launched, waited for home
162
+ s.rc[2] = 1800
163
+ s.wait(6) # sim seconds, on the autopilot's clock
164
+ print(s.height(), s.provenance["ardupilot_sha"])
165
+ # SITL's process group is killed, its ports are free, the lease is released
166
+ ```
167
+
168
+ `instance=None` (the default) takes the first pool instance whose lock is free and whose ports
169
+ (MAVLink TCP, the JSON backend, and a bridge's own) nothing else holds; `instance=42` asks for one
170
+ and raises `InstanceBusy` naming the holder. A SITL that exits or PANICs raises `SitlDied` after
171
+ being killed. A script that dies without `close()` still kills its SITL at exit; one that is
172
+ SIGKILLed leaves `<run_root>/<n>/sitl.pid`, which `mcardupilot.sitl.kill_instance` reads.
173
+
174
+ `mcardupilot.mission` uploads items given in metres from home (`nav`, `do`, `box_mission`),
175
+ retrying while ArduPilot ignores mission traffic after boot, and reads a mission back.
176
+ `keep_logs="last"` with `log_dest=` moves a flight's BIN out of the instance directory at
177
+ `close()`; `mcardupilot.logs.prune` deletes the oldest logs past `log_budget_gb`, never one that is
178
+ pinned (`logs.pin`), written in the last ten minutes, or under a SITL that is still running.
179
+
180
+ A subclass sets its own state, then calls `start()`, and overrides `tick(kind, msg)` to drive
181
+ time-varying inputs from every pumped message. A `Bridge` (see `sitl.py`) plugs in an external
182
+ physics process on SITL's JSON backend.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ uv run pytest -q # about 20 s on 8 workers, against tests/fake_autopilot.py
188
+ uv run pytest -m sitl # real hops on a stock arduplane (MCARDUPILOT_STOCK_ARDUPILOT)
189
+ uvx ruff check . && uvx ruff format --check .
190
+ ```
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "mcardupilot"
3
+ version = "2026.10.03"
4
+ description = "ArduPilot SITL instances, flights and logs for LLM agents"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Ryan Malloy", email = "ryan@supported.systems" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["ardupilot", "sitl", "mavlink", "mcp", "fastmcp"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering",
21
+ ]
22
+ dependencies = [
23
+ "fastmcp>=4,<5",
24
+ "pymavlink>=2.4.50",
25
+ "pydantic>=2",
26
+ "pydantic-settings>=2.15.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Documentation = "https://mcardupilot.warehack.ing"
31
+
32
+ [project.scripts]
33
+ mcardupilot = "mcardupilot.server:main"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=9.1.1",
38
+ "pytest-asyncio>=1.4.0",
39
+ "pytest-xdist>=3.8.0",
40
+ "ruff>=0.16.10",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
46
+ markers = [
47
+ "sitl: launches a built SITL binary; run with -m sitl",
48
+ "harness: reads a private test harness checkout; run with -m harness",
49
+ ]
50
+ # 8 workers: most of a test's time is waiting on a fake autopilot, and conftest gives
51
+ # each worker its own fake instances; -n 0 runs serially
52
+ addopts = "-m 'not sitl and not harness' -n 8"
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ target-version = "py312"
57
+
58
+ [tool.ruff.lint]
59
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
60
+
61
+ [tool.uv]
62
+ # `uvx --from <checkout> mcardupilot` rebuilds when the source changes, not only
63
+ # pyproject.toml (uv's default cache key for a local directory)
64
+ cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.py" }]
65
+
66
+ [tool.uv.build-backend]
67
+ # Operator-private and local files never ship. Tests stay out of the sdist too:
68
+ # fixtures are the first place a local path or a real log would land.
69
+ source-exclude = [
70
+ "CLAUDE.md",
71
+ ".env",
72
+ ".env.*",
73
+ ".mcp.json",
74
+ "tests",
75
+ "docs/agent-threads",
76
+ ]
77
+
78
+ [build-system]
79
+ requires = ["uv_build>=0.11.3,<0.12.0"]
80
+ build-backend = "uv_build"
@@ -0,0 +1,8 @@
1
+ """mcardupilot: ArduPilot SITL instances, flights and logs for LLM agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("mcardupilot")
7
+ except PackageNotFoundError: # running from a checkout without install
8
+ __version__ = "0.0.0"
@@ -0,0 +1,151 @@
1
+ """Server settings, read from the environment with the MCARDUPILOT_ prefix."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field, field_validator, model_validator
8
+ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
9
+
10
+ # Instances no pool may contain, with the reason the error gives.
11
+ RESERVED_INSTANCES = {
12
+ 0: "reserved for autotest.py",
13
+ 14: "its ports collide with VNC desktops on 5900 to 5911",
14
+ 15: "its ports collide with VNC desktops on 5900 to 5911",
15
+ }
16
+ # SITL's highest base port is the JSON backend at 9002 + 10n (it also uses 9003 + 10n).
17
+ MAX_INSTANCE = (65535 - 9003) // 10
18
+
19
+ _TOKEN = re.compile(r"^(\d+)(?:-(\d+))?$")
20
+
21
+
22
+ def mavlink_port(instance: int) -> int:
23
+ return 5760 + 10 * instance
24
+
25
+
26
+ def json_port(instance: int) -> int:
27
+ return 9002 + 10 * instance
28
+
29
+
30
+ def parse_pool(text: str) -> list[int]:
31
+ """Read "40-59" or "40,41,45-47" into instance numbers, in the order written."""
32
+ out: list[int] = []
33
+ for token in (t.strip() for t in text.split(",")):
34
+ if not token:
35
+ continue
36
+ m = _TOKEN.match(token)
37
+ if m is None:
38
+ raise ValueError(f"cannot read {token!r} in instance pool {text!r}; use '40-59'")
39
+ lo = int(m.group(1))
40
+ hi = int(m.group(2)) if m.group(2) else lo
41
+ if hi < lo:
42
+ raise ValueError(f"range {token!r} runs backwards")
43
+ out.extend(range(lo, hi + 1))
44
+ return out
45
+
46
+
47
+ def format_pool(pool: list[int]) -> str:
48
+ """The inverse of parse_pool: runs of three or more ascending become a-b."""
49
+ parts: list[str] = []
50
+ i = 0
51
+ while i < len(pool):
52
+ j = i
53
+ while j + 1 < len(pool) and pool[j + 1] == pool[j] + 1:
54
+ j += 1
55
+ if j - i >= 2:
56
+ parts.append(f"{pool[i]}-{pool[j]}")
57
+ else:
58
+ parts.extend(str(n) for n in pool[i : j + 1])
59
+ i = j + 1
60
+ return ",".join(parts)
61
+
62
+
63
+ class Settings(BaseSettings):
64
+ model_config = SettingsConfigDict(env_prefix="MCARDUPILOT_", env_file=".env", extra="ignore")
65
+
66
+ ardupilot: Path = Field(
67
+ default=Path.home() / "ardupilot", # where ArduPilot's setup guide clones it
68
+ description="ArduPilot tree; read and run only, never built or edited",
69
+ )
70
+ binary: Path | None = Field(
71
+ default=None, description="SITL binary; default <ardupilot>/build/sitl/bin/arduplane"
72
+ )
73
+ data_dir: Path = Field(
74
+ default=Path.home() / ".local" / "share" / "mcardupilot",
75
+ description="lease locks, session run directories, kept logs",
76
+ )
77
+
78
+ instance_pool: Annotated[list[int], NoDecode] = Field(
79
+ default_factory=lambda: list(range(40, 60)),
80
+ description='instances this server may lease, "40-59" or "40,41,45-47"',
81
+ )
82
+
83
+ wait_cap_s: float = Field(default=85.0, description="longest any waiting tool blocks")
84
+ session_idle_s: float = Field(default=1800.0, description="close a session idle this long")
85
+ reap_interval_s: float = Field(default=30.0, description="how often idle sessions are checked")
86
+ stall_s: float = Field(
87
+ default=30.0, description="a session's SITL whose clock stops this long is dead"
88
+ )
89
+ log_budget_gb: float = Field(
90
+ default=20.0, gt=0, description="kept dataflash logs prune past this"
91
+ )
92
+
93
+ # transport: stdio for one client; http is the seam for a shared deployment
94
+ transport: Literal["stdio", "http"] = "stdio"
95
+ http_host: str = "127.0.0.1"
96
+ http_port: int = 8374
97
+
98
+ @field_validator("instance_pool", mode="before")
99
+ @classmethod
100
+ def _read_pool(cls, v: object) -> object:
101
+ if isinstance(v, str):
102
+ return parse_pool(v)
103
+ if isinstance(v, int):
104
+ return [v]
105
+ return v
106
+
107
+ @field_validator("instance_pool")
108
+ @classmethod
109
+ def _check_pool(cls, pool: list[int]) -> list[int]:
110
+ for n in pool:
111
+ if n in RESERVED_INSTANCES:
112
+ raise ValueError(f"instance {n} cannot be pooled: {RESERVED_INSTANCES[n]}")
113
+ if not 0 <= n <= MAX_INSTANCE:
114
+ raise ValueError(f"instance {n} is outside 1 to {MAX_INSTANCE}")
115
+ dups = sorted({n for n in pool if pool.count(n) > 1})
116
+ if dups:
117
+ raise ValueError(f"instance pool lists {dups} more than once")
118
+ return pool
119
+
120
+ @field_validator("ardupilot", "binary", "data_dir")
121
+ @classmethod
122
+ def _expand(cls, p: Path | None) -> Path | None:
123
+ return p.expanduser() if p is not None else None
124
+
125
+ @model_validator(mode="after")
126
+ def _default_binary(self) -> "Settings":
127
+ if self.binary is None:
128
+ self.binary = self.ardupilot / "build" / "sitl" / "bin" / "arduplane"
129
+ return self
130
+
131
+ @property
132
+ def sitl_binary(self) -> Path:
133
+ """binary, typed as always set (the validator fills it)."""
134
+ assert self.binary is not None
135
+ return self.binary
136
+
137
+ @property
138
+ def leases_dir(self) -> Path:
139
+ return self.data_dir / "leases"
140
+
141
+ @property
142
+ def sessions_dir(self) -> Path:
143
+ return self.data_dir / "sessions"
144
+
145
+ @property
146
+ def logs_dir(self) -> Path:
147
+ return self.data_dir / "logs"
148
+
149
+ @property
150
+ def jobs_dir(self) -> Path:
151
+ return self.data_dir / "jobs"