@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
package/bridge/README.md
ADDED
|
@@ -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,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)
|