@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,163 @@
1
+ # UAV Pack — Live-Link MCP Bridge (Tier R read-only + gated Tier C param read/write)
2
+
3
+ A thin, **firmware-agnostic** MCP server (stdio transport, [FastMCP]) that exposes
4
+ ArduPilot / PX4 **telemetry and parameters** to an MCP client (Hydron's `uav`
5
+ persona). It speaks MAVLink through `pymavlink.mavutil`, so it serves **every UAV
6
+ lab — ArduPilot and PX4** — over a live link **or** an offline log replay.
7
+
8
+ It implements the read-only **Tier R** tool contract plus a **gated Tier C
9
+ parameter read/write** tier — the firmware-track on-ramp. See the spec:
10
+ [`internal-docs/.../uav/10-live-link-mcp-bridge.md`](../../internal-docs/wiki/engineering/features/domain-plugins/uav/10-live-link-mcp-bridge.md).
11
+
12
+ ## Flight command/control is NEVER here
13
+
14
+ This server registers **no flight command/control tools** — in **any** mode.
15
+ There is no arm/disarm, no `set_mode`, no takeoff/land/goto, and no mission
16
+ upload. A tool that is not registered cannot be called *or* sit in the client's
17
+ context — that is the strongest gate (per the spec's "server build is the true
18
+ hide"). Enabling a live-C2 flight surface would be a deliberate, separate change
19
+ on a dedicated `uav-sitl` persona — and is **not** what the Tier C tier below is.
20
+
21
+ ## Tools (Tier R — read-only)
22
+
23
+ | Tool | Purpose |
24
+ |---|---|
25
+ | `read_params()` | Dump current parameters as `{name: value}` (PARM / PARAM_VALUE). |
26
+ | `get_messages(types, limit=100)` | Recent messages of the given type(s) as a list of dicts. `types` is a string or list. |
27
+ | `tail_telemetry(seconds=5.0)` | Poll telemetry for N seconds; returns accumulated recent values (latest-per-type + samples). |
28
+ | `get_flight_mode()` | Current / last-seen flight mode (e.g. `LOITER`). |
29
+ | `get_status()` | Best-effort armed state, GPS fix, battery, link health. |
30
+
31
+ ## Tools (Tier C — parameter read/write, the firmware-track on-ramp)
32
+
33
+ Tier C is **parameter read/write ONLY** — the param-upload + retest loop (e.g.
34
+ pushing an `INS_HNTCH_*` notch-filter tune, then re-flying and comparing). It is
35
+ **not** flight command-and-control: arm/takeoff/land/mission stay intentionally
36
+ absent regardless of any flag.
37
+
38
+ | Tool | Gate | Purpose |
39
+ |---|---|---|
40
+ | `param_get(name)` | always on | Read one param via `PARAM_REQUEST_READ`. Returns `{name, value, found}`. |
41
+ | `param_get_all()` | always on | Explicit live full dump via `PARAM_REQUEST_LIST`. Returns `{count, params}` (same read path as `read_params`). |
42
+ | `param_set(name, value)` | **gated** | Set one param via `PARAM_SET`, read back the `PARAM_VALUE` echo to confirm. Returns `{name, requested, confirmed, ok, ts}`. |
43
+ | `param_set_batch(params)` | **gated** | Set several params, confirm each (the notch-tune push). Returns per-param `results`, confirmed `set_params`, and `started_ts`/`finished_ts` for **retest tagging**. |
44
+
45
+ ### Write gate (defense-in-depth, default OFF)
46
+
47
+ The param **write** tools (`param_set`, `param_set_batch`) are usable **only**
48
+ when the bridge is started **WITHOUT** `--read-only` (i.e. `--no-read-only`)
49
+ **AND WITH** the explicit `--allow-param-write` flag. Both are required; either
50
+ alone keeps writes off.
51
+
52
+ - **Gate closed (default):** the write tools are registered as **disabled
53
+ stubs** that return a clear structured error — `"param write disabled (start
54
+ the bridge WITHOUT --read-only AND WITH --allow-param-write …)"` — never a
55
+ silent or accidental write.
56
+ - **Gate open:** the real write tools are registered.
57
+ - The param **reads** (`param_get`, `param_get_all`) are always available; they
58
+ have no vehicle side effects.
59
+
60
+ **The persona must ALSO allow-list the write tool names.** The server gate and
61
+ the persona permission ruleset are independent layers: in `.hydron/agent/uav.md`
62
+ (or a dedicated tuning persona) you must `allow`/`ask` `mavlink_param_set` and
63
+ `mavlink_param_set_batch`. With the server gate closed, those tools still refuse;
64
+ with it open but the persona not allow-listing them, the client blocks the call.
65
+
66
+ ### Retest tagging hook
67
+
68
+ `param_set_batch` returns the confirmed `set_params` (name→value) plus
69
+ `started_ts`/`finished_ts`. The uav-tuning subagent uses this to **tag the next
70
+ flight as a retest** — Hydron attaches the changed params + timestamps to the
71
+ flight's tags. The bridge writes **no files**; it only returns structured data.
72
+
73
+ The MCP tools are thin wrappers around a `MavlinkSource` class that holds all
74
+ read/write logic, so tests (and other callers) can use
75
+ `MavlinkSource(path).read_params()` / `.param_set(...)` directly without starting
76
+ the server.
77
+
78
+ ## Connection strings
79
+
80
+ The server takes a connection string from env `MAVLINK_CONNECT` (default) or the
81
+ `--connect` arg, passed straight to `mavutil.mavlink_connection`:
82
+
83
+ | Form | Example | Notes |
84
+ |---|---|---|
85
+ | UDP | `udp:127.0.0.1:14550` | Mission Planner / SITL forward |
86
+ | TCP | `tcp:host:port` | |
87
+ | Serial | `/dev/ttyUSB0` | direct flight controller |
88
+ | **File** | `/path/to/flight.bin` or `*.tlog` | **offline replay** — how we test without a vehicle |
89
+
90
+ Optional `--dialect` / env `MAVLINK_DIALECT` (e.g. `ardupilotmega`).
91
+
92
+ ## Register with Hydron — `config.mcp`
93
+
94
+ ```jsonc
95
+ // .hydron/.../opencode.jsonc — ArduPilot lab, read-only by default
96
+ "mcp": {
97
+ "mavlink": {
98
+ "type": "local",
99
+ "command": ["python", "uav_mavlink_bridge.py", "--connect", "udp:127.0.0.1:14550", "--read-only"],
100
+ "environment": { "MAVLINK_DIALECT": "ardupilotmega" },
101
+ "timeout": 10000
102
+ }
103
+ }
104
+ ```
105
+
106
+ ```jsonc
107
+ // Tuning lab — Tier C param WRITE enabled (firmware-track on-ramp).
108
+ // Pair with a persona that allow-lists mavlink_param_set / mavlink_param_set_batch.
109
+ "mcp": {
110
+ "mavlink": {
111
+ "type": "local",
112
+ "command": ["python", "uav_mavlink_bridge.py", "--connect", "udp:127.0.0.1:14550",
113
+ "--no-read-only", "--allow-param-write"],
114
+ "environment": { "MAVLINK_DIALECT": "ardupilotmega" },
115
+ "timeout": 10000
116
+ }
117
+ }
118
+ ```
119
+
120
+ The bridge name (`mavlink`) becomes the tool prefix, so tools surface as
121
+ `mavlink_read_params`, `mavlink_param_set`, etc.
122
+
123
+ Persona permission additions (`.hydron/agent/uav.md`): `allow` the Tier R reads
124
+ and the Tier C param **reads** (`mavlink_param_get`, `mavlink_param_get_all`). To
125
+ use the param **writes**, the persona must additionally `allow`/`ask`
126
+ `mavlink_param_set` and `mavlink_param_set_batch` — and the server must be
127
+ launched with `--no-read-only --allow-param-write`. Continue to `deny` every
128
+ flight command/control tool name as defense-in-depth.
129
+
130
+ ## Run / develop
131
+
132
+ ```bash
133
+ pip install -r requirements.txt
134
+
135
+ # Offline replay (no vehicle):
136
+ python3 uav_mavlink_bridge.py --connect "/path/to/flight.bin"
137
+
138
+ # Live link (Tier R + Tier C param reads):
139
+ MAVLINK_CONNECT=udp:127.0.0.1:14550 python3 uav_mavlink_bridge.py
140
+
141
+ # Live link with Tier C param WRITE enabled (firmware-track tuning):
142
+ MAVLINK_CONNECT=udp:127.0.0.1:14550 python3 uav_mavlink_bridge.py --no-read-only --allow-param-write
143
+
144
+ # Tests (offline replay against a real log):
145
+ python3 -m pytest test_bridge.py -q
146
+ ```
147
+
148
+ ## Robustness
149
+
150
+ - No flight command/control tools registered, **by construction**, in any mode.
151
+ - Param **write** is gated OFF by default; the write tools are disabled stubs
152
+ until `--no-read-only --allow-param-write` opens the gate.
153
+ - A write against an offline log replay is a **structured no-op** (returns
154
+ `ok: False` with a reason) — never a crash, never a phantom write.
155
+ - `param_set` confirms via the `PARAM_VALUE` echo (float-rounding tolerant)
156
+ before reporting `ok: True`.
157
+ - Every tool wraps its body in `try/except` and returns a structured error dict
158
+ (`{"error": True, "tool": ..., "message": ...}`) rather than crashing the
159
+ stdio server.
160
+ - Bounded recv loops (per-tool time budget + message cap) so no tool can hang
161
+ forever, on a live link or a huge log.
162
+
163
+ [FastMCP]: https://github.com/modelcontextprotocol/python-sdk
@@ -0,0 +1,2 @@
1
+ pymavlink>=2.4.49
2
+ mcp[cli]>=1.0.0
@@ -0,0 +1,153 @@
1
+ """Offline-replay tests for the UAV MAVLink->MCP bridge.
2
+
3
+ These drive ``MavlinkSource`` directly against a real DataFlash log — no live
4
+ socket, no MCP server started. Run:
5
+
6
+ cd <hydron-uav-pack>/bridge
7
+ UAV_TEST_LOG=/path/to/flight.bin python3 -m pytest test_bridge.py -q
8
+ # or: UAV_TEST_LOG=/path/to/flight.bin python3 test_bridge.py
9
+ """
10
+
11
+ import os
12
+ import unittest
13
+
14
+ from uav_mavlink_bridge import MavlinkSource, build_server
15
+
16
+ # Point at a real DataFlash log via UAV_TEST_LOG; tests skip if unset/missing.
17
+ LOG_PATH = os.environ.get("UAV_TEST_LOG", "")
18
+
19
+
20
+ def _source():
21
+ # A fresh source per call: file replay consumes the connection, so each
22
+ # tool needs its own pass over the log.
23
+ return MavlinkSource(LOG_PATH)
24
+
25
+
26
+ @unittest.skipUnless(os.path.exists(LOG_PATH), f"log not found: {LOG_PATH}")
27
+ class TestMavlinkSourceOffline(unittest.TestCase):
28
+ def test_read_params(self):
29
+ params = _source().read_params()
30
+ self.assertIsInstance(params, dict)
31
+ self.assertIn("SYSID_THISMAV", params)
32
+ self.assertGreater(len(params), 100, "expected >100 params")
33
+ # Value is numeric.
34
+ self.assertIsInstance(params["SYSID_THISMAV"], float)
35
+
36
+ def test_get_messages_vibe(self):
37
+ msgs = _source().get_messages("VIBE", limit=10)
38
+ self.assertIsInstance(msgs, list)
39
+ self.assertGreater(len(msgs), 0)
40
+ self.assertLessEqual(len(msgs), 10)
41
+ for m in msgs:
42
+ self.assertIn("VibeZ", m)
43
+
44
+ def test_get_messages_multi_type(self):
45
+ msgs = _source().get_messages(["ATT", "BAT"])
46
+ types = {m.get("mavpackettype") for m in msgs}
47
+ self.assertIn("ATT", types)
48
+ self.assertIn("BAT", types)
49
+
50
+ def test_get_flight_mode(self):
51
+ res = _source().get_flight_mode()
52
+ self.assertIsInstance(res, dict)
53
+ self.assertTrue(res.get("mode"))
54
+ self.assertNotEqual(res["mode"], "UNKNOWN")
55
+
56
+ def test_get_status(self):
57
+ res = _source().get_status()
58
+ self.assertIsInstance(res, dict)
59
+ populated = [
60
+ res.get("armed") is not None,
61
+ bool(res.get("gps")),
62
+ bool(res.get("battery")),
63
+ ]
64
+ self.assertTrue(any(populated), f"status had nothing populated: {res}")
65
+
66
+
67
+ @unittest.skipUnless(os.path.exists(LOG_PATH), f"log not found: {LOG_PATH}")
68
+ class TestTierCParamReadOffline(unittest.TestCase):
69
+ """Tier C param READS reuse the read path and work on a log replay."""
70
+
71
+ def test_param_get_all_includes_sysid(self):
72
+ params = _source().param_get_all()
73
+ self.assertIsInstance(params, dict)
74
+ self.assertIn("SYSID_THISMAV", params)
75
+ self.assertIsInstance(params["SYSID_THISMAV"], float)
76
+
77
+ def test_param_get_single(self):
78
+ res = _source().param_get("SYSID_THISMAV")
79
+ self.assertIsInstance(res, dict)
80
+ self.assertTrue(res.get("found"))
81
+ self.assertIsInstance(res.get("value"), float)
82
+
83
+ def test_param_get_missing(self):
84
+ res = _source().param_get("DEFINITELY_NOT_A_REAL_PARAM")
85
+ self.assertFalse(res.get("found"))
86
+ self.assertIsNone(res.get("value"))
87
+
88
+
89
+ @unittest.skipUnless(os.path.exists(LOG_PATH), f"log not found: {LOG_PATH}")
90
+ class TestTierCParamWriteGate(unittest.TestCase):
91
+ """The param WRITE gate: closed by default, structured no-op on a file source."""
92
+
93
+ def _registered_tools(self, **kwargs):
94
+ mcp = build_server(LOG_PATH, **kwargs)
95
+ return {t.name for t in mcp._tool_manager.list_tools()}
96
+
97
+ def test_gate_off_by_default_set_returns_disabled(self):
98
+ # Gate OFF (default): no crash, no write — a clear "disabled" error.
99
+ mcp = build_server(LOG_PATH) # read_only=True, allow_param_write=False
100
+ # The direct source also refuses because a file is not writable.
101
+ src_res = _source().param_set("INS_HNTCH_FREQ", 80)
102
+ self.assertFalse(src_res.get("ok"))
103
+ self.assertIsNone(src_res.get("confirmed"))
104
+ self.assertIn("reason", src_res)
105
+ # And the write tools are the disabled variant when the gate is closed.
106
+ names = {t.name for t in mcp._tool_manager.list_tools()}
107
+ self.assertIn("param_get", names)
108
+ self.assertIn("param_get_all", names)
109
+ # Registered (as disabled stubs) but harmless.
110
+ self.assertIn("param_set", names)
111
+
112
+ def test_gate_off_stub_says_disabled(self):
113
+ from uav_mavlink_bridge import MavlinkSource as _MS # noqa: F401
114
+ mcp = build_server(LOG_PATH, read_only=True, allow_param_write=False)
115
+ tool = mcp._tool_manager.get_tool("param_set")
116
+ out = tool.fn(name="INS_HNTCH_FREQ", value=80)
117
+ self.assertFalse(out.get("ok"))
118
+ self.assertTrue(out.get("error"))
119
+ self.assertIn("disabled", out.get("reason", "").lower())
120
+ self.assertIn("allow-param-write", out.get("reason", ""))
121
+
122
+ def test_gate_on_file_source_is_noop_not_crash(self):
123
+ # Gate ON but the source is a log file: structured no-op, never a crash.
124
+ mcp = build_server(LOG_PATH, read_only=False, allow_param_write=True)
125
+ names = {t.name for t in mcp._tool_manager.list_tools()}
126
+ self.assertIn("param_set", names)
127
+ self.assertIn("param_set_batch", names)
128
+ # Direct source call: refuses because a file is not a live vehicle.
129
+ res = _source().param_set("INS_HNTCH_FREQ", 80)
130
+ self.assertFalse(res.get("ok"))
131
+ self.assertIsNone(res.get("confirmed"))
132
+ self.assertIn("log replay", res.get("reason", ""))
133
+
134
+ def test_batch_on_file_source_returns_tagging_shape(self):
135
+ # Even blocked, the batch result carries the timestamps/shape the
136
+ # uav-tuning subagent needs to tag a retest.
137
+ res = _source().param_set_batch({
138
+ "INS_HNTCH_FREQ": 80,
139
+ "INS_HNTCH_BW": 40,
140
+ })
141
+ self.assertFalse(res.get("ok"))
142
+ self.assertIn("started_ts", res)
143
+ self.assertIn("finished_ts", res)
144
+ self.assertIn("set_params", res)
145
+ self.assertEqual(res["set_params"], {})
146
+
147
+ def test_close_enough(self):
148
+ self.assertTrue(MavlinkSource._close_enough(80.0, 80.00001))
149
+ self.assertFalse(MavlinkSource._close_enough(80.0, 81.0))
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main(verbosity=2)