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