@h2loop/uav 1.0.0
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.
- package/.hydron-plugin/plugin.json +3 -0
- package/README.md +281 -0
- package/USAGE.md +172 -0
- package/agents/uav-compare.md +36 -0
- package/agents/uav-investigate.md +60 -0
- package/agents/uav-verify.md +45 -0
- package/bridge/README.md +163 -0
- package/bridge/requirements.txt +2 -0
- package/bridge/test_bridge.py +153 -0
- package/bridge/uav_mavlink_bridge.py +799 -0
- package/commands/uav-batch-report.md +20 -0
- package/commands/uav-compare.md +14 -0
- package/commands/uav-preflight.md +12 -0
- package/commands/uav-save-finding.md +25 -0
- package/commands/uav-tune.md +12 -0
- package/data/apm-params.json +1 -0
- package/data/uav-format-converters.example.json +17 -0
- package/mcp.json +15 -0
- package/package.json +43 -0
- package/plugins/index.js +46 -0
- package/plugins/worker/worker-entry.js +11 -0
- package/skills/uav/SKILL.md +101 -0
- package/skills/uav/references/checks.md +174 -0
- package/skills/uav/references/compare.md +49 -0
- package/skills/uav/references/grounding.md +94 -0
- package/skills/uav/references/project-config.md +27 -0
- package/skills/uav/references/signals.md +60 -0
- package/skills/uav/references/tuning-methodology.md +79 -0
- package/skills/uav/references/tuning.md +72 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""UAV Pack — Live-Link MCP Bridge (Tier R read-only + Tier C param read/write).
|
|
3
|
+
|
|
4
|
+
A firmware-agnostic MCP server (stdio transport, FastMCP) that exposes
|
|
5
|
+
ArduPilot / PX4 telemetry and parameters to an MCP client (Hydron's `uav`
|
|
6
|
+
persona). It speaks MAVLink via ``pymavlink.mavutil``, so it serves every UAV
|
|
7
|
+
lab — ArduPilot *and* PX4 — and works against a live link (udp/tcp/serial) or
|
|
8
|
+
an offline log replay (a ``.bin`` DataFlash log or ``.tlog`` telemetry log).
|
|
9
|
+
|
|
10
|
+
Spec: internal-docs/wiki/.../uav/10-live-link-mcp-bridge.md
|
|
11
|
+
|
|
12
|
+
============================ READ-ONLY BY CONSTRUCTION ============================
|
|
13
|
+
By default this server implements ONLY the Tier R (read-only) tool contract:
|
|
14
|
+
|
|
15
|
+
read_params, get_messages, tail_telemetry, get_flight_mode, get_status
|
|
16
|
+
|
|
17
|
+
It registers NO flight command/control tools. There is no arm/disarm, no
|
|
18
|
+
set_mode, no takeoff/land/goto, and no mission upload — EVER, in any mode. A tool
|
|
19
|
+
that is not registered cannot be called *or* sit in the client's context — that
|
|
20
|
+
is the strongest gate.
|
|
21
|
+
|
|
22
|
+
============================ TIER C — PARAMETER READ/WRITE ============================
|
|
23
|
+
A SECOND, GATED tier exposes PARAMETER read/write — and ONLY parameter
|
|
24
|
+
read/write. This is the on-ramp to the *firmware track*: the param-upload +
|
|
25
|
+
retest loop (e.g. pushing an ``INS_HNTCH_*`` notch-filter tune, then re-flying
|
|
26
|
+
and comparing). It is deliberately NOT flight command-and-control:
|
|
27
|
+
|
|
28
|
+
param_get, param_get_all, param_set, param_set_batch
|
|
29
|
+
|
|
30
|
+
These are registered ONLY when the server is started WITHOUT ``--read-only`` AND
|
|
31
|
+
WITH the explicit ``--allow-param-write`` flag (default OFF, defense-in-depth).
|
|
32
|
+
With the gate off, the write tools (param_set / param_set_batch) either are not
|
|
33
|
+
registered at all, or return a clear "param write disabled" error — never a
|
|
34
|
+
silent or accidental write. The persona must ALSO allow-list these tool names
|
|
35
|
+
(``mavlink_param_set`` / ``mavlink_param_set_batch``) in its permission ruleset;
|
|
36
|
+
the server gate and the persona allow-list are independent layers.
|
|
37
|
+
|
|
38
|
+
Flight command/control (arm/takeoff/land/mission/goto/set_mode) remains
|
|
39
|
+
INTENTIONALLY ABSENT regardless of any flag. Param write is the firmware-track
|
|
40
|
+
on-ramp; it is not, and will not become, a live-C2 flight surface.
|
|
41
|
+
|
|
42
|
+
Design note: the MCP tool functions are thin wrappers that delegate to a small
|
|
43
|
+
``MavlinkSource`` class holding all read/write logic, so tests can drive
|
|
44
|
+
``MavlinkSource(path).read_params()`` directly without starting the server.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import argparse
|
|
50
|
+
import os
|
|
51
|
+
import time
|
|
52
|
+
from typing import Any, Dict, List, Optional, Union
|
|
53
|
+
|
|
54
|
+
from pymavlink import mavutil
|
|
55
|
+
|
|
56
|
+
# Default per-tool recv budget so a tool can never hang the server forever.
|
|
57
|
+
DEFAULT_TOOL_TIMEOUT = 8.0
|
|
58
|
+
# How long to wait on any single recv_match() call (live links only).
|
|
59
|
+
RECV_TIMEOUT = 1.0
|
|
60
|
+
# Hard cap on messages pulled while replaying a file, so a huge log can't stall.
|
|
61
|
+
FILE_SCAN_CAP = 2_000_000
|
|
62
|
+
|
|
63
|
+
# DataFlash (.bin) log message types vs. live/MAVLink network message types.
|
|
64
|
+
# The bridge supports both transparently.
|
|
65
|
+
_PARAM_TYPES = ("PARM", "PARAM_VALUE")
|
|
66
|
+
_GPS_TYPES = ("GPS", "GPS_RAW_INT")
|
|
67
|
+
_BATTERY_TYPES = ("BAT", "BATTERY_STATUS", "SYS_STATUS")
|
|
68
|
+
_ARM_TYPES = ("ARM",)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _msg_to_dict(msg: Any) -> Dict[str, Any]:
|
|
72
|
+
"""Convert a pymavlink message to a plain dict, with a best-effort timestamp."""
|
|
73
|
+
d: Dict[str, Any]
|
|
74
|
+
try:
|
|
75
|
+
d = dict(msg.to_dict())
|
|
76
|
+
except Exception:
|
|
77
|
+
d = {"_type": msg.get_type()}
|
|
78
|
+
ts = getattr(msg, "_timestamp", None)
|
|
79
|
+
if ts is not None and "_timestamp" not in d:
|
|
80
|
+
d["_timestamp"] = ts
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MavlinkSource:
|
|
85
|
+
"""Wraps a single ``mavutil`` connection and implements the Tier R reads.
|
|
86
|
+
|
|
87
|
+
Accepts any connection string ``mavutil.mavlink_connection`` understands:
|
|
88
|
+
``udp:127.0.0.1:14550``, ``tcp:host:port``, ``/dev/ttyUSB0``, or a FILE PATH
|
|
89
|
+
(``.bin`` / ``.tlog``) for offline replay.
|
|
90
|
+
|
|
91
|
+
Not thread-safe: a single underlying connection is reused. The MCP server
|
|
92
|
+
creates one shared instance; concurrent tool calls are effectively
|
|
93
|
+
serialized by the recv loop. For the read-only analysis workload this is
|
|
94
|
+
fine and keeps the link single-owner.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, conn: str, dialect: Optional[str] = None):
|
|
98
|
+
self.conn = conn
|
|
99
|
+
self.dialect = dialect or os.environ.get("MAVLINK_DIALECT") or None
|
|
100
|
+
self.is_file = self._looks_like_file(conn)
|
|
101
|
+
kwargs: Dict[str, Any] = {}
|
|
102
|
+
if self.dialect:
|
|
103
|
+
kwargs["dialect"] = self.dialect
|
|
104
|
+
self.master = mavutil.mavlink_connection(conn, **kwargs)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _looks_like_file(conn: str) -> bool:
|
|
108
|
+
lowered = conn.lower()
|
|
109
|
+
for proto in ("udp:", "udpin:", "udpout:", "tcp:", "tcpin:", "com", "/dev/"):
|
|
110
|
+
if lowered.startswith(proto):
|
|
111
|
+
return False
|
|
112
|
+
return os.path.exists(conn)
|
|
113
|
+
|
|
114
|
+
def _recv(self, **kwargs):
|
|
115
|
+
"""recv_match with sensible defaults for file vs live links."""
|
|
116
|
+
if self.is_file:
|
|
117
|
+
# Files replay synchronously; non-blocking returns None at EOF.
|
|
118
|
+
return self.master.recv_match(blocking=False, **kwargs)
|
|
119
|
+
kwargs.setdefault("timeout", RECV_TIMEOUT)
|
|
120
|
+
return self.master.recv_match(blocking=True, **kwargs)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------ Tier R
|
|
123
|
+
|
|
124
|
+
def read_params(self, timeout: float = 30.0) -> Dict[str, float]:
|
|
125
|
+
"""Return the current parameter set as a ``name -> value`` map.
|
|
126
|
+
|
|
127
|
+
File source: scan PARM / PARAM_VALUE messages (last value wins).
|
|
128
|
+
Live source: request a full param fetch and collect PARAM_VALUE until the
|
|
129
|
+
expected count arrives or the timeout elapses.
|
|
130
|
+
"""
|
|
131
|
+
params: Dict[str, float] = {}
|
|
132
|
+
|
|
133
|
+
if self.is_file:
|
|
134
|
+
count = 0
|
|
135
|
+
while count < FILE_SCAN_CAP:
|
|
136
|
+
msg = self.master.recv_match(type=list(_PARAM_TYPES), blocking=False)
|
|
137
|
+
if msg is None:
|
|
138
|
+
break
|
|
139
|
+
count += 1
|
|
140
|
+
name, value = self._param_kv(msg)
|
|
141
|
+
if name is not None:
|
|
142
|
+
params[name] = value
|
|
143
|
+
return params
|
|
144
|
+
|
|
145
|
+
# Live link: ask the vehicle to stream every parameter.
|
|
146
|
+
try:
|
|
147
|
+
self.master.wait_heartbeat(timeout=RECV_TIMEOUT)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
self.master.mav.param_request_list_send(
|
|
151
|
+
self.master.target_system, self.master.target_component
|
|
152
|
+
)
|
|
153
|
+
deadline = time.time() + timeout
|
|
154
|
+
expected: Optional[int] = None
|
|
155
|
+
while time.time() < deadline:
|
|
156
|
+
msg = self.master.recv_match(type="PARAM_VALUE", blocking=True, timeout=RECV_TIMEOUT)
|
|
157
|
+
if msg is None:
|
|
158
|
+
if expected is not None and len(params) >= expected:
|
|
159
|
+
break
|
|
160
|
+
continue
|
|
161
|
+
name, value = self._param_kv(msg)
|
|
162
|
+
if name is not None:
|
|
163
|
+
params[name] = value
|
|
164
|
+
expected = getattr(msg, "param_count", expected) or expected
|
|
165
|
+
if expected and len(params) >= expected:
|
|
166
|
+
break
|
|
167
|
+
return params
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _param_kv(msg: Any):
|
|
171
|
+
t = msg.get_type()
|
|
172
|
+
if t == "PARM": # DataFlash
|
|
173
|
+
return getattr(msg, "Name", None), float(getattr(msg, "Value", 0.0))
|
|
174
|
+
if t == "PARAM_VALUE": # live MAVLink
|
|
175
|
+
name = getattr(msg, "param_id", None)
|
|
176
|
+
if isinstance(name, bytes):
|
|
177
|
+
name = name.decode("utf-8", "replace")
|
|
178
|
+
if name is not None:
|
|
179
|
+
name = name.rstrip("\x00")
|
|
180
|
+
return name, float(getattr(msg, "param_value", 0.0))
|
|
181
|
+
return None, None
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------- Tier C: param read/write
|
|
184
|
+
|
|
185
|
+
def param_get_all(self, timeout: float = 30.0) -> Dict[str, float]:
|
|
186
|
+
"""Explicit live form of the full param fetch (PARAM_REQUEST_LIST).
|
|
187
|
+
|
|
188
|
+
Returns a ``name -> value`` map. This shares the exact read path as
|
|
189
|
+
``read_params`` (file scan or live PARAM_REQUEST_LIST), so it works for
|
|
190
|
+
offline replay too — it is the read half of the param read/write tier.
|
|
191
|
+
"""
|
|
192
|
+
return self.read_params(timeout=timeout)
|
|
193
|
+
|
|
194
|
+
def param_get(self, name: str, timeout: float = 10.0) -> Dict[str, Any]:
|
|
195
|
+
"""Read a single parameter by name via PARAM_REQUEST_READ (live).
|
|
196
|
+
|
|
197
|
+
Returns ``{"name", "value", "found"}``. On a file source there is no
|
|
198
|
+
live request channel, so we scan the log for the last value seen.
|
|
199
|
+
"""
|
|
200
|
+
name = (name or "").strip()
|
|
201
|
+
if not name:
|
|
202
|
+
return {"name": name, "value": None, "found": False, "reason": "empty name"}
|
|
203
|
+
|
|
204
|
+
if self.is_file:
|
|
205
|
+
# No live request on a log; fall back to the full scan and look up.
|
|
206
|
+
params = self.read_params(timeout=timeout)
|
|
207
|
+
if name in params:
|
|
208
|
+
return {"name": name, "value": params[name], "found": True}
|
|
209
|
+
return {"name": name, "value": None, "found": False}
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
self.master.wait_heartbeat(timeout=RECV_TIMEOUT)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
# param_index = -1 means "look up by name".
|
|
216
|
+
self.master.mav.param_request_read_send(
|
|
217
|
+
self.master.target_system,
|
|
218
|
+
self.master.target_component,
|
|
219
|
+
name.encode("utf-8"),
|
|
220
|
+
-1,
|
|
221
|
+
)
|
|
222
|
+
deadline = time.time() + timeout
|
|
223
|
+
while time.time() < deadline:
|
|
224
|
+
msg = self.master.recv_match(type="PARAM_VALUE", blocking=True, timeout=RECV_TIMEOUT)
|
|
225
|
+
if msg is None:
|
|
226
|
+
continue
|
|
227
|
+
n, v = self._param_kv(msg)
|
|
228
|
+
if n == name:
|
|
229
|
+
return {"name": name, "value": v, "found": True}
|
|
230
|
+
return {"name": name, "value": None, "found": False, "reason": "timeout"}
|
|
231
|
+
|
|
232
|
+
def _can_write(self) -> Optional[str]:
|
|
233
|
+
"""Return a reason string if a write is NOT possible, else None.
|
|
234
|
+
|
|
235
|
+
A file/log replay is not a live vehicle; there is no writable target, so
|
|
236
|
+
param_set must be a structured no-op rather than a crash.
|
|
237
|
+
"""
|
|
238
|
+
if self.is_file:
|
|
239
|
+
return "source is an offline log replay; no live vehicle to write to"
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def param_set(self, name: str, value: float, timeout: float = 10.0) -> Dict[str, Any]:
|
|
243
|
+
"""Set a single parameter, then read back to confirm via PARAM_VALUE echo.
|
|
244
|
+
|
|
245
|
+
Returns ``{name, requested, confirmed, ok, ts}`` (plus ``reason`` on
|
|
246
|
+
failure). Confirmation tolerates float rounding from the autopilot's
|
|
247
|
+
param storage. This is param write ONLY — never a flight command.
|
|
248
|
+
"""
|
|
249
|
+
name = (name or "").strip()
|
|
250
|
+
ts = time.time()
|
|
251
|
+
if not name:
|
|
252
|
+
return {"name": name, "requested": value, "confirmed": None,
|
|
253
|
+
"ok": False, "ts": ts, "reason": "empty name"}
|
|
254
|
+
|
|
255
|
+
blocked = self._can_write()
|
|
256
|
+
if blocked is not None:
|
|
257
|
+
return {"name": name, "requested": value, "confirmed": None,
|
|
258
|
+
"ok": False, "ts": ts, "reason": blocked}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
req = float(value)
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
return {"name": name, "requested": value, "confirmed": None,
|
|
264
|
+
"ok": False, "ts": ts, "reason": "value is not numeric"}
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
self.master.wait_heartbeat(timeout=RECV_TIMEOUT)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
# mavutil.param_set_send packs PARAM_SET (handles name encoding + type).
|
|
272
|
+
self.master.param_set_send(name, req)
|
|
273
|
+
|
|
274
|
+
# Read back the PARAM_VALUE echo to confirm the write took.
|
|
275
|
+
confirmed: Optional[float] = None
|
|
276
|
+
deadline = time.time() + timeout
|
|
277
|
+
while time.time() < deadline:
|
|
278
|
+
msg = self.master.recv_match(type="PARAM_VALUE", blocking=True, timeout=RECV_TIMEOUT)
|
|
279
|
+
if msg is None:
|
|
280
|
+
continue
|
|
281
|
+
n, v = self._param_kv(msg)
|
|
282
|
+
if n == name:
|
|
283
|
+
confirmed = v
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
ok = confirmed is not None and self._close_enough(req, confirmed)
|
|
287
|
+
result: Dict[str, Any] = {
|
|
288
|
+
"name": name,
|
|
289
|
+
"requested": req,
|
|
290
|
+
"confirmed": confirmed,
|
|
291
|
+
"ok": ok,
|
|
292
|
+
"ts": ts,
|
|
293
|
+
}
|
|
294
|
+
if not ok:
|
|
295
|
+
result["reason"] = "no PARAM_VALUE echo" if confirmed is None else "echo mismatch"
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _close_enough(a: float, b: float) -> bool:
|
|
300
|
+
"""Confirm a param echo, tolerating float storage rounding."""
|
|
301
|
+
try:
|
|
302
|
+
return abs(float(a) - float(b)) <= max(1e-4, abs(float(a)) * 1e-4)
|
|
303
|
+
except (TypeError, ValueError):
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
def param_set_batch(self, params: Dict[str, float], timeout: float = 10.0) -> Dict[str, Any]:
|
|
307
|
+
"""Set several parameters, confirming each (e.g. the INS_HNTCH_* notch push).
|
|
308
|
+
|
|
309
|
+
Returns a structured result carrying enough for the caller (the
|
|
310
|
+
uav-tuning subagent) to TAG A RETEST: the per-param outcomes, the set of
|
|
311
|
+
confirmed name->value pairs, and start/end timestamps. Hydron attaches
|
|
312
|
+
these to the next flight's tags. This writes files NOWHERE — it only
|
|
313
|
+
returns structured data.
|
|
314
|
+
"""
|
|
315
|
+
started = time.time()
|
|
316
|
+
if not isinstance(params, dict) or not params:
|
|
317
|
+
return {"ok": False, "count": 0, "results": [], "set_params": {},
|
|
318
|
+
"started_ts": started, "finished_ts": time.time(),
|
|
319
|
+
"reason": "params must be a non-empty {name: value} dict"}
|
|
320
|
+
|
|
321
|
+
blocked = self._can_write()
|
|
322
|
+
if blocked is not None:
|
|
323
|
+
# Single clear blocked result rather than per-param noise.
|
|
324
|
+
return {"ok": False, "count": len(params), "results": [], "set_params": {},
|
|
325
|
+
"started_ts": started, "finished_ts": time.time(), "reason": blocked}
|
|
326
|
+
|
|
327
|
+
results: List[Dict[str, Any]] = []
|
|
328
|
+
set_params: Dict[str, float] = {}
|
|
329
|
+
for name, value in params.items():
|
|
330
|
+
r = self.param_set(name, value, timeout=timeout)
|
|
331
|
+
results.append(r)
|
|
332
|
+
if r.get("ok"):
|
|
333
|
+
set_params[r["name"]] = r["confirmed"]
|
|
334
|
+
|
|
335
|
+
all_ok = bool(results) and all(r.get("ok") for r in results)
|
|
336
|
+
return {
|
|
337
|
+
"ok": all_ok,
|
|
338
|
+
"count": len(results),
|
|
339
|
+
"ok_count": sum(1 for r in results if r.get("ok")),
|
|
340
|
+
"results": results,
|
|
341
|
+
"set_params": set_params, # confirmed name->value, for retest tagging
|
|
342
|
+
"started_ts": started,
|
|
343
|
+
"finished_ts": time.time(),
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
def get_messages(
|
|
347
|
+
self,
|
|
348
|
+
types: Union[str, List[str]],
|
|
349
|
+
limit: int = 100,
|
|
350
|
+
) -> List[Dict[str, Any]]:
|
|
351
|
+
"""Return up to ``limit`` recent messages of the given type(s) as dicts."""
|
|
352
|
+
if isinstance(types, str):
|
|
353
|
+
type_list = [types]
|
|
354
|
+
else:
|
|
355
|
+
type_list = list(types)
|
|
356
|
+
type_list = [t.strip() for t in type_list if t and t.strip()]
|
|
357
|
+
if not type_list:
|
|
358
|
+
return []
|
|
359
|
+
if limit <= 0:
|
|
360
|
+
return []
|
|
361
|
+
|
|
362
|
+
out: List[Dict[str, Any]] = []
|
|
363
|
+
|
|
364
|
+
if self.is_file:
|
|
365
|
+
# Keep only the most recent `limit` per requested type so we return
|
|
366
|
+
# *recent* messages regardless of where they sit in the log.
|
|
367
|
+
from collections import deque
|
|
368
|
+
|
|
369
|
+
buffers = {t: deque(maxlen=limit) for t in type_list}
|
|
370
|
+
count = 0
|
|
371
|
+
while count < FILE_SCAN_CAP:
|
|
372
|
+
msg = self.master.recv_match(type=type_list, blocking=False)
|
|
373
|
+
if msg is None:
|
|
374
|
+
break
|
|
375
|
+
count += 1
|
|
376
|
+
buffers[msg.get_type()].append(_msg_to_dict(msg))
|
|
377
|
+
for t in type_list:
|
|
378
|
+
out.extend(buffers[t])
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
# Live link: collect until we have `limit` total or run out of budget.
|
|
382
|
+
deadline = time.time() + DEFAULT_TOOL_TIMEOUT
|
|
383
|
+
while len(out) < limit and time.time() < deadline:
|
|
384
|
+
msg = self._recv(type=type_list)
|
|
385
|
+
if msg is None:
|
|
386
|
+
continue
|
|
387
|
+
out.append(_msg_to_dict(msg))
|
|
388
|
+
return out
|
|
389
|
+
|
|
390
|
+
def tail_telemetry(self, seconds: float = 5.0) -> Dict[str, Any]:
|
|
391
|
+
"""Poll telemetry for ~N seconds; return accumulated recent values.
|
|
392
|
+
|
|
393
|
+
Returns a dict with the latest value seen per message type plus a count
|
|
394
|
+
and a flat sample list. For a file, we replay up to N seconds of log time
|
|
395
|
+
(or a message cap) rather than wall-clock waiting.
|
|
396
|
+
"""
|
|
397
|
+
seconds = max(0.0, float(seconds))
|
|
398
|
+
latest: Dict[str, Dict[str, Any]] = {}
|
|
399
|
+
samples: List[Dict[str, Any]] = []
|
|
400
|
+
total = 0
|
|
401
|
+
|
|
402
|
+
if self.is_file:
|
|
403
|
+
cap = int(min(FILE_SCAN_CAP, max(1000, seconds * 4000)))
|
|
404
|
+
start_ts: Optional[float] = None
|
|
405
|
+
while total < cap:
|
|
406
|
+
msg = self.master.recv_match(blocking=False)
|
|
407
|
+
if msg is None:
|
|
408
|
+
break
|
|
409
|
+
d = _msg_to_dict(msg)
|
|
410
|
+
ts = d.get("_timestamp")
|
|
411
|
+
if ts is not None:
|
|
412
|
+
if start_ts is None:
|
|
413
|
+
start_ts = ts
|
|
414
|
+
elif seconds > 0 and (ts - start_ts) > seconds:
|
|
415
|
+
break
|
|
416
|
+
t = msg.get_type()
|
|
417
|
+
latest[t] = d
|
|
418
|
+
samples.append(d)
|
|
419
|
+
total += 1
|
|
420
|
+
else:
|
|
421
|
+
deadline = time.time() + seconds
|
|
422
|
+
while time.time() < deadline:
|
|
423
|
+
msg = self._recv()
|
|
424
|
+
if msg is None:
|
|
425
|
+
continue
|
|
426
|
+
d = _msg_to_dict(msg)
|
|
427
|
+
t = msg.get_type()
|
|
428
|
+
latest[t] = d
|
|
429
|
+
samples.append(d)
|
|
430
|
+
total += 1
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"seconds": seconds,
|
|
434
|
+
"count": total,
|
|
435
|
+
"types": sorted(latest.keys()),
|
|
436
|
+
"latest": latest,
|
|
437
|
+
"samples": samples[-200:], # cap payload size
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
def get_flight_mode(self) -> Dict[str, Any]:
|
|
441
|
+
"""Return the current / last-seen flight mode."""
|
|
442
|
+
# Replay/advance the connection so mavutil's internal mode tracking
|
|
443
|
+
# (`self.master.flightmode`) is populated. For a file this consumes it,
|
|
444
|
+
# so we read MODE/HEARTBEAT directly too.
|
|
445
|
+
mode_str: Optional[str] = None
|
|
446
|
+
mode_num: Optional[int] = None
|
|
447
|
+
|
|
448
|
+
if self.is_file:
|
|
449
|
+
count = 0
|
|
450
|
+
last = None
|
|
451
|
+
while count < FILE_SCAN_CAP:
|
|
452
|
+
msg = self.master.recv_match(type=["MODE", "HEARTBEAT"], blocking=False)
|
|
453
|
+
if msg is None:
|
|
454
|
+
break
|
|
455
|
+
count += 1
|
|
456
|
+
last = msg
|
|
457
|
+
if last is not None:
|
|
458
|
+
mode_num = getattr(last, "ModeNum", getattr(last, "Mode", None))
|
|
459
|
+
mode_str = getattr(self.master, "flightmode", None)
|
|
460
|
+
else:
|
|
461
|
+
try:
|
|
462
|
+
self.master.wait_heartbeat(timeout=RECV_TIMEOUT)
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
msg = self._recv(type="HEARTBEAT")
|
|
466
|
+
mode_str = getattr(self.master, "flightmode", None)
|
|
467
|
+
if msg is not None:
|
|
468
|
+
mode_num = getattr(msg, "custom_mode", None)
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
"mode": mode_str or "UNKNOWN",
|
|
472
|
+
"mode_num": mode_num,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
def get_status(self) -> Dict[str, Any]:
|
|
476
|
+
"""Best-effort armed state, GPS fix, battery, and link health.
|
|
477
|
+
|
|
478
|
+
Pulls from HEARTBEAT/SYS_STATUS/GPS_RAW_INT on a live link, or the
|
|
479
|
+
DataFlash equivalents (ARM/GPS/BAT/MODE) on a file replay.
|
|
480
|
+
"""
|
|
481
|
+
armed: Optional[bool] = None
|
|
482
|
+
gps: Optional[Dict[str, Any]] = None
|
|
483
|
+
battery: Optional[Dict[str, Any]] = None
|
|
484
|
+
link: Dict[str, Any] = {"healthy": None, "messages_seen": 0}
|
|
485
|
+
|
|
486
|
+
wanted = list(
|
|
487
|
+
set(("HEARTBEAT", "MODE") + _GPS_TYPES + _BATTERY_TYPES + _ARM_TYPES)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if self.is_file:
|
|
491
|
+
count = 0
|
|
492
|
+
while count < FILE_SCAN_CAP:
|
|
493
|
+
msg = self.master.recv_match(type=wanted, blocking=False)
|
|
494
|
+
if msg is None:
|
|
495
|
+
break
|
|
496
|
+
count += 1
|
|
497
|
+
self._fold_status(msg, locals_ref=None)
|
|
498
|
+
t = msg.get_type()
|
|
499
|
+
if t == "ARM":
|
|
500
|
+
armed = bool(getattr(msg, "ArmState", 0))
|
|
501
|
+
elif t == "HEARTBEAT":
|
|
502
|
+
base = getattr(msg, "base_mode", 0)
|
|
503
|
+
armed = bool(base & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
|
504
|
+
elif t in _GPS_TYPES:
|
|
505
|
+
gps = self._gps_dict(msg)
|
|
506
|
+
elif t in _BATTERY_TYPES:
|
|
507
|
+
battery = self._battery_dict(msg) or battery
|
|
508
|
+
link["messages_seen"] += 1
|
|
509
|
+
link["healthy"] = link["messages_seen"] > 0
|
|
510
|
+
else:
|
|
511
|
+
deadline = time.time() + DEFAULT_TOOL_TIMEOUT
|
|
512
|
+
seen = 0
|
|
513
|
+
while time.time() < deadline and (armed is None or gps is None or battery is None):
|
|
514
|
+
msg = self._recv(type=wanted)
|
|
515
|
+
if msg is None:
|
|
516
|
+
continue
|
|
517
|
+
seen += 1
|
|
518
|
+
t = msg.get_type()
|
|
519
|
+
if t == "HEARTBEAT":
|
|
520
|
+
base = getattr(msg, "base_mode", 0)
|
|
521
|
+
armed = bool(base & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
|
|
522
|
+
elif t in _GPS_TYPES:
|
|
523
|
+
gps = self._gps_dict(msg)
|
|
524
|
+
elif t in _BATTERY_TYPES:
|
|
525
|
+
b = self._battery_dict(msg)
|
|
526
|
+
if b:
|
|
527
|
+
battery = b
|
|
528
|
+
link["messages_seen"] = seen
|
|
529
|
+
link["healthy"] = seen > 0
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
"armed": armed,
|
|
533
|
+
"gps": gps,
|
|
534
|
+
"battery": battery,
|
|
535
|
+
"link": link,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
def _fold_status(self, msg, locals_ref=None): # placeholder kept for clarity
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _gps_dict(msg) -> Dict[str, Any]:
|
|
543
|
+
t = msg.get_type()
|
|
544
|
+
if t == "GPS": # DataFlash
|
|
545
|
+
return {
|
|
546
|
+
"fix_type": getattr(msg, "Status", None),
|
|
547
|
+
"satellites": getattr(msg, "NSats", None),
|
|
548
|
+
"hdop": getattr(msg, "HDop", None),
|
|
549
|
+
"lat": getattr(msg, "Lat", None),
|
|
550
|
+
"lon": getattr(msg, "Lng", None),
|
|
551
|
+
"alt": getattr(msg, "Alt", None),
|
|
552
|
+
}
|
|
553
|
+
# GPS_RAW_INT (live, scaled ints)
|
|
554
|
+
lat = getattr(msg, "lat", None)
|
|
555
|
+
lon = getattr(msg, "lon", None)
|
|
556
|
+
return {
|
|
557
|
+
"fix_type": getattr(msg, "fix_type", None),
|
|
558
|
+
"satellites": getattr(msg, "satellites_visible", None),
|
|
559
|
+
"hdop": (getattr(msg, "eph", None) or 0) / 100.0 if getattr(msg, "eph", None) else None,
|
|
560
|
+
"lat": lat / 1e7 if lat is not None else None,
|
|
561
|
+
"lon": lon / 1e7 if lon is not None else None,
|
|
562
|
+
"alt": (getattr(msg, "alt", None) or 0) / 1000.0 if getattr(msg, "alt", None) is not None else None,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def _battery_dict(msg) -> Optional[Dict[str, Any]]:
|
|
567
|
+
t = msg.get_type()
|
|
568
|
+
if t == "BAT": # DataFlash
|
|
569
|
+
return {
|
|
570
|
+
"voltage": getattr(msg, "Volt", None),
|
|
571
|
+
"current": getattr(msg, "Curr", None),
|
|
572
|
+
"remaining_pct": getattr(msg, "RemPct", None),
|
|
573
|
+
"consumed_mah": getattr(msg, "CurrTot", None),
|
|
574
|
+
}
|
|
575
|
+
if t == "SYS_STATUS": # live
|
|
576
|
+
v = getattr(msg, "voltage_battery", None)
|
|
577
|
+
c = getattr(msg, "current_battery", None)
|
|
578
|
+
return {
|
|
579
|
+
"voltage": v / 1000.0 if v not in (None, 0xFFFF) else None,
|
|
580
|
+
"current": c / 100.0 if c not in (None, -1) else None,
|
|
581
|
+
"remaining_pct": getattr(msg, "battery_remaining", None),
|
|
582
|
+
"consumed_mah": None,
|
|
583
|
+
}
|
|
584
|
+
if t == "BATTERY_STATUS": # live
|
|
585
|
+
volts = getattr(msg, "voltages", None)
|
|
586
|
+
v = None
|
|
587
|
+
if volts:
|
|
588
|
+
first = volts[0]
|
|
589
|
+
v = first / 1000.0 if first not in (None, 0xFFFF) else None
|
|
590
|
+
return {
|
|
591
|
+
"voltage": v,
|
|
592
|
+
"current": (getattr(msg, "current_battery", None) or 0) / 100.0,
|
|
593
|
+
"remaining_pct": getattr(msg, "battery_remaining", None),
|
|
594
|
+
"consumed_mah": getattr(msg, "current_consumed", None),
|
|
595
|
+
}
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
def close(self) -> None:
|
|
599
|
+
try:
|
|
600
|
+
self.master.close()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --------------------------------------------------------------------------- #
|
|
606
|
+
# MCP server. The tools are thin wrappers around a shared MavlinkSource and #
|
|
607
|
+
# each catches everything, returning a structured error dict instead of #
|
|
608
|
+
# crashing the stdio server. #
|
|
609
|
+
# --------------------------------------------------------------------------- #
|
|
610
|
+
|
|
611
|
+
def _err(tool: str, exc: Exception) -> Dict[str, Any]:
|
|
612
|
+
return {"error": True, "tool": tool, "type": type(exc).__name__, "message": str(exc)}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def build_server(
|
|
616
|
+
conn: str,
|
|
617
|
+
dialect: Optional[str] = None,
|
|
618
|
+
read_only: bool = True,
|
|
619
|
+
allow_param_write: bool = False,
|
|
620
|
+
):
|
|
621
|
+
"""Construct the FastMCP server bound to one source.
|
|
622
|
+
|
|
623
|
+
Always registers the Tier R reads and the Tier C param *reads*
|
|
624
|
+
(``param_get`` / ``param_get_all``). The Tier C param *writes*
|
|
625
|
+
(``param_set`` / ``param_set_batch``) are registered ONLY when the gate is
|
|
626
|
+
open: ``read_only`` is False AND ``allow_param_write`` is True. When the gate
|
|
627
|
+
is closed, the write tools are NOT registered (the strongest hide) — calling
|
|
628
|
+
them is impossible, not merely refused.
|
|
629
|
+
|
|
630
|
+
No flight command/control tool is ever registered, in any mode.
|
|
631
|
+
"""
|
|
632
|
+
from mcp.server.fastmcp import FastMCP
|
|
633
|
+
|
|
634
|
+
mcp = FastMCP("mavlink")
|
|
635
|
+
source = MavlinkSource(conn, dialect=dialect)
|
|
636
|
+
param_write_enabled = (not read_only) and bool(allow_param_write)
|
|
637
|
+
|
|
638
|
+
@mcp.tool()
|
|
639
|
+
def read_params() -> Dict[str, Any]:
|
|
640
|
+
"""Dump current vehicle parameters as a {name: value} map (read-only)."""
|
|
641
|
+
try:
|
|
642
|
+
params = source.read_params()
|
|
643
|
+
return {"count": len(params), "params": params}
|
|
644
|
+
except Exception as exc: # noqa: BLE001
|
|
645
|
+
return _err("read_params", exc)
|
|
646
|
+
|
|
647
|
+
@mcp.tool()
|
|
648
|
+
def get_messages(types: Union[str, List[str]], limit: int = 100) -> Dict[str, Any]:
|
|
649
|
+
"""Return recent MAVLink messages of the given type(s) as a list of dicts."""
|
|
650
|
+
try:
|
|
651
|
+
msgs = source.get_messages(types, limit=limit)
|
|
652
|
+
return {"count": len(msgs), "messages": msgs}
|
|
653
|
+
except Exception as exc: # noqa: BLE001
|
|
654
|
+
return _err("get_messages", exc)
|
|
655
|
+
|
|
656
|
+
@mcp.tool()
|
|
657
|
+
def tail_telemetry(seconds: float = 5.0) -> Dict[str, Any]:
|
|
658
|
+
"""Poll telemetry for N seconds and return accumulated recent values."""
|
|
659
|
+
try:
|
|
660
|
+
return source.tail_telemetry(seconds=seconds)
|
|
661
|
+
except Exception as exc: # noqa: BLE001
|
|
662
|
+
return _err("tail_telemetry", exc)
|
|
663
|
+
|
|
664
|
+
@mcp.tool()
|
|
665
|
+
def get_flight_mode() -> Dict[str, Any]:
|
|
666
|
+
"""Return the current / last-seen flight mode."""
|
|
667
|
+
try:
|
|
668
|
+
return source.get_flight_mode()
|
|
669
|
+
except Exception as exc: # noqa: BLE001
|
|
670
|
+
return _err("get_flight_mode", exc)
|
|
671
|
+
|
|
672
|
+
@mcp.tool()
|
|
673
|
+
def get_status() -> Dict[str, Any]:
|
|
674
|
+
"""Return armed state, GPS fix, battery, and link health (best-effort)."""
|
|
675
|
+
try:
|
|
676
|
+
return source.get_status()
|
|
677
|
+
except Exception as exc: # noqa: BLE001
|
|
678
|
+
return _err("get_status", exc)
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------ Tier C: param read/write
|
|
681
|
+
# Param READS are always available (read-only, no vehicle side effects).
|
|
682
|
+
@mcp.tool()
|
|
683
|
+
def param_get(name: str) -> Dict[str, Any]:
|
|
684
|
+
"""Read one parameter by name via PARAM_REQUEST_READ (read-only)."""
|
|
685
|
+
try:
|
|
686
|
+
return source.param_get(name)
|
|
687
|
+
except Exception as exc: # noqa: BLE001
|
|
688
|
+
return _err("param_get", exc)
|
|
689
|
+
|
|
690
|
+
@mcp.tool()
|
|
691
|
+
def param_get_all() -> Dict[str, Any]:
|
|
692
|
+
"""Explicit live full-param dump via PARAM_REQUEST_LIST (read-only)."""
|
|
693
|
+
try:
|
|
694
|
+
params = source.param_get_all()
|
|
695
|
+
return {"count": len(params), "params": params}
|
|
696
|
+
except Exception as exc: # noqa: BLE001
|
|
697
|
+
return _err("param_get_all", exc)
|
|
698
|
+
|
|
699
|
+
# Param WRITES are GATED: registered only with the gate open.
|
|
700
|
+
# The persona MUST also allow-list `mavlink_param_set` / `mavlink_param_set_batch`.
|
|
701
|
+
if param_write_enabled:
|
|
702
|
+
@mcp.tool()
|
|
703
|
+
def param_set(name: str, value: float) -> Dict[str, Any]:
|
|
704
|
+
"""Set one parameter and confirm via PARAM_VALUE echo (Tier C write).
|
|
705
|
+
|
|
706
|
+
On-ramp to the firmware track (param-upload + retest). NOT a flight
|
|
707
|
+
command. Returns {name, requested, confirmed, ok, ts}.
|
|
708
|
+
"""
|
|
709
|
+
try:
|
|
710
|
+
return source.param_set(name, value)
|
|
711
|
+
except Exception as exc: # noqa: BLE001
|
|
712
|
+
return _err("param_set", exc)
|
|
713
|
+
|
|
714
|
+
@mcp.tool()
|
|
715
|
+
def param_set_batch(params: Dict[str, float]) -> Dict[str, Any]:
|
|
716
|
+
"""Set several params, confirm each (e.g. the INS_HNTCH_* notch tune).
|
|
717
|
+
|
|
718
|
+
Returns per-param results plus confirmed `set_params` and
|
|
719
|
+
start/finish timestamps so the uav-tuning subagent can tag the next
|
|
720
|
+
flight as a retest. NOT a flight command. Writes no files.
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
return source.param_set_batch(params)
|
|
724
|
+
except Exception as exc: # noqa: BLE001
|
|
725
|
+
return _err("param_set_batch", exc)
|
|
726
|
+
else:
|
|
727
|
+
# Gate closed: register stubs that explain how to enable, so a caller
|
|
728
|
+
# gets a clear, structured error instead of an unknown-tool failure.
|
|
729
|
+
_DISABLED = (
|
|
730
|
+
"param write disabled (start the bridge WITHOUT --read-only AND "
|
|
731
|
+
"WITH --allow-param-write, and allow-list the tool in the persona)"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
@mcp.tool()
|
|
735
|
+
def param_set(name: str, value: float) -> Dict[str, Any]:
|
|
736
|
+
"""Disabled: param write is gated off (Tier C). See message."""
|
|
737
|
+
return {"name": name, "requested": value, "confirmed": None,
|
|
738
|
+
"ok": False, "ts": time.time(), "error": True,
|
|
739
|
+
"tool": "param_set", "reason": _DISABLED, "message": _DISABLED}
|
|
740
|
+
|
|
741
|
+
@mcp.tool()
|
|
742
|
+
def param_set_batch(params: Dict[str, float]) -> Dict[str, Any]:
|
|
743
|
+
"""Disabled: param write is gated off (Tier C). See message."""
|
|
744
|
+
return {"ok": False, "count": 0, "results": [], "set_params": {},
|
|
745
|
+
"started_ts": time.time(), "finished_ts": time.time(),
|
|
746
|
+
"error": True, "tool": "param_set_batch",
|
|
747
|
+
"reason": _DISABLED, "message": _DISABLED}
|
|
748
|
+
|
|
749
|
+
# NOTE: No FLIGHT command/control tools (arm/takeoff/land/mission/set_mode)
|
|
750
|
+
# are registered here, by design — in ANY mode. Tier C is param read/write
|
|
751
|
+
# only; it is the firmware-track on-ramp, not a live-C2 flight surface.
|
|
752
|
+
return mcp
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _parse_args(argv=None):
|
|
756
|
+
p = argparse.ArgumentParser(
|
|
757
|
+
description="MAVLink -> MCP bridge (Tier R reads + gated Tier C param read/write)"
|
|
758
|
+
)
|
|
759
|
+
p.add_argument(
|
|
760
|
+
"--connect",
|
|
761
|
+
default=os.environ.get("MAVLINK_CONNECT", "udp:127.0.0.1:14550"),
|
|
762
|
+
help="connection string: udp:host:port | tcp:host:port | /dev/ttyUSB0 | FILE.bin/.tlog",
|
|
763
|
+
)
|
|
764
|
+
p.add_argument("--dialect", default=os.environ.get("MAVLINK_DIALECT"))
|
|
765
|
+
# Read-only mode: the default. Tier R reads + Tier C param reads only; the
|
|
766
|
+
# Tier C param WRITE tools are not registered. Use --no-read-only (or simply
|
|
767
|
+
# omit and pass --allow-param-write) to open the write tier.
|
|
768
|
+
p.add_argument(
|
|
769
|
+
"--read-only",
|
|
770
|
+
dest="read_only",
|
|
771
|
+
action="store_true",
|
|
772
|
+
default=True,
|
|
773
|
+
help="read-only: no param-write tools registered (DEFAULT)",
|
|
774
|
+
)
|
|
775
|
+
p.add_argument(
|
|
776
|
+
"--no-read-only",
|
|
777
|
+
dest="read_only",
|
|
778
|
+
action="store_false",
|
|
779
|
+
help="drop read-only; REQUIRED (with --allow-param-write) to enable param writes",
|
|
780
|
+
)
|
|
781
|
+
p.add_argument(
|
|
782
|
+
"--allow-param-write",
|
|
783
|
+
dest="allow_param_write",
|
|
784
|
+
action="store_true",
|
|
785
|
+
default=False,
|
|
786
|
+
help="enable Tier C param WRITE tools (only takes effect WITH --no-read-only; default OFF)",
|
|
787
|
+
)
|
|
788
|
+
return p.parse_args(argv)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
if __name__ == "__main__":
|
|
792
|
+
args = _parse_args()
|
|
793
|
+
server = build_server(
|
|
794
|
+
args.connect,
|
|
795
|
+
dialect=args.dialect,
|
|
796
|
+
read_only=args.read_only,
|
|
797
|
+
allow_param_write=args.allow_param_write,
|
|
798
|
+
)
|
|
799
|
+
server.run() # stdio transport
|