A read_log tool and six MCP resources over the log and the catalogue

read_log fetches the live log over FTP and filters by tag, severity and
message before it returns anything, because half an hour of uptime is
five thousand entries. It excludes shp_usbmode by default, a five-second
USB poll that can be a third of the log, and says in the result that it
did so. Only the current file is reachable: the archives the drone
rotates into are a previous owner's sessions and carry network and
location data, which is a decision for a person, not a tool.

bebop://commands is the resource worth having, because it needs no
drone: the whole catalogue is readable with the aircraft switched off.
The ones that do need a link return an explanation rather than raising,
since a resource that errors looks broken while one that explains itself
is empty for a reason. bebop://state also reports telemetry age, because
the aircraft accepts a second controller and silently redirects
telemetry to it, so a connected flag proves nothing.

The log says nothing resolvable about ARSDK, measured rather than
assumed: no command ids appear anywhere in it, and the COMMANDS function
names match arsdk-xml for 4 of 11 symbols, 3.5% of the tag. So nothing
here resolves them, and read_log says where to look instead.

_host moves into tools/_common as drone_host now that three tool modules
need it, and head/tail in the shell allow-list gains the reason they
must take a file argument: ld.so.preload makes SIGPIPE fatal, so a pipe
into head writes a crash report to the aircraft's flash.
This commit is contained in:
2026-10-02 03:40:17 -06:00
parent 38322b6885
commit ad81cc6e38
11 changed files with 887 additions and 47 deletions
+6
View File
@@ -53,6 +53,12 @@ ALLOWED = frozenset(
"bcmwl", # Broadcom wireless tool: regulatory domain, channel, rates
"df",
"mount",
# Always with a FILE argument. `/etc/ld.so.preload` on this drone
# preloads a crash-dump library into every process and it treats
# SIGPIPE as fatal, so `producer | head` makes the producer dump a
# ~350 KB crash report onto the aircraft's flash. FORBIDDEN_CHARS
# already makes a pipe unbuildable from here; the hazard is anyone who
# relaxes that, or who runs a command on the drone by hand.
"head",
"tail",
}
+23
View File
@@ -87,6 +87,29 @@ class FileEntry(BaseModel):
is_dir: bool = False
class LogEntry(BaseModel):
uptime: float = Field(description="Seconds since the drone booted. The log has no wall-clock time.")
level: str = Field(description="critical, error, warning, notice, info or debug.")
tag: str = Field(description="Which subsystem logged it, e.g. KERNEL, COMMANDS, NETWORK.")
source: str = Field(default="", description="process/thread that logged it, where the drone says.")
message: str
class LogRead(BaseModel):
path: str = Field(description="Where the fetched log landed on this machine.")
size: int = Field(description="Bytes fetched. The drone is still appending to it.")
parsed: int = Field(description="Entries found in the whole file, before any filter.")
matched: int = Field(description="Entries the filters kept. More than `returned` means capped.")
returned: int
newest_first: bool
tags: dict[str, int] = Field(
default_factory=dict,
description="Entry count per tag across the whole file, so you can see what else is in there.",
)
note: str = Field(default="", description="Anything odd about the parse, such as a truncated tail.")
entries: list[LogEntry] = Field(default_factory=list)
class ArmState(BaseModel):
armed: bool
reason: str = ""
+204
View File
@@ -0,0 +1,204 @@
"""MCP resources: addressable, read-only views of the protocol and the aircraft.
Resources are for things a client wants to *look at* rather than *do*, and the
split that matters here is whether the drone has to be powered on.
`bebop://commands` is the valuable one precisely because it does not need the
drone. The whole command set, 264 of them, is readable with the aircraft in a
bag: an agent can learn what the protocol offers, pick what it needs and only
then ask someone to switch a drone on. Everything under `bebop://state`,
`bebop://files` and `bebop://log` needs a live link, and says so in its body
instead of raising, because a resource that errors out looks broken while one
that explains itself is just empty for a reason.
"""
import asyncio
import logging
from typing import Any
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcbebop.config import Settings
from mcbebop.files import ftp
from mcbebop.protocol import xml_index
from mcbebop.tools import logs, protocol
from mcbebop.tools._common import app, drone_host
logger = logging.getLogger(__name__)
JSON = "application/json"
#: Enough to see what a subsystem has been up to without filling a context.
LOG_RESOURCE_LIMIT = 80
def _unavailable(reason: str, **extra: Any) -> dict:
"""A body that explains an empty resource instead of an exception."""
# Logged as well as returned: a client that only shows the resource as
# empty leaves no trace of why, and this is the one place that knows.
logger.debug("resource unavailable: %s", reason)
return {"available": False, "reason": reason, **extra}
def _session():
"""The live session, or None. Resources degrade; they do not raise."""
state = app()
return state.session if state.connected else None
def register(mcp: FastMCP, settings: Settings) -> None:
@mcp.resource(
"bebop://commands",
name="ARSDK command catalogue",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"Every command and event Parrot defines for this aircraft, with its tier, direction and "
"whether the XML says the Bebop 2 supports it. Needs no drone, so it is the place to work "
"out what is possible before anything is powered on. Read one in full at "
"bebop://commands/{name}."
),
)
def commands() -> dict:
specs = sorted(xml_index.all_commands(), key=lambda s: s.full_name)
return {
"count": len(specs),
"tiers": "observe and config are open; envelope and motion need arm()",
"commands": [protocol.summary(s).model_dump() for s in specs],
}
@mcp.resource(
"bebop://commands/{name}",
name="One ARSDK command",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"One command in full: arguments with their types and enum values, the documentation Parrot "
"wrote, the wire ids, which link buffer carries it and which events confirm it. The name is "
"the dotted form, e.g. ardrone3.Piloting.TakeOff."
),
)
def command(name: str) -> dict:
spec = xml_index.get(name)
if spec is None:
near = [c.full_name for c in xml_index.search(name)][:8]
return _unavailable(
f"No command named {name!r}. Browse bebop://commands for the full list.",
did_you_mean=near,
)
return protocol.detail(spec).model_dump()
@mcp.resource(
"bebop://state",
name="Telemetry snapshot",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"Everything the drone has reported about itself, each value with how many seconds ago it "
"said it. Age is the point: these are the last thing it said, not a fresh reading, so a "
"large age means the link went quiet rather than that the value is current."
),
)
def state() -> dict:
session = _session()
if session is None:
return _unavailable("No drone session. Call connect() first.")
raw = session.state()
# `connected` is not evidence of a working link. The aircraft accepts a
# second controller and silently redirects telemetry to it, leaving
# this session reporting connected while nothing arrives, so the age
# of the newest value is what tells a quiet link from a live one.
stats = session.link_stats()
return {
"available": True,
"keys": len(raw),
"last_event_age": stats.get("last_event_age"),
"link": "a last_event_age of more than a few seconds means telemetry has stopped arriving",
"values": {k: {"value": v["value"], "age": v["age"]} for k, v in raw.items()},
}
@mcp.resource(
"bebop://state/{key}",
name="One telemetry value",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"One reported value and its age. The key is '<Command>_<arg>', such as "
"BatteryStateChanged_percent; a bare command name matches every argument under it."
),
)
def state_key(key: str) -> dict:
session = _session()
if session is None:
return _unavailable("No drone session. Call connect() first.", key=key)
raw = session.state([key])
if not raw:
return _unavailable(
f"The drone has not reported anything under {key!r}. "
"bebop://state lists every key it has sent.",
key=key,
)
return {"available": True, "key": key, "values": raw}
@mcp.resource(
"bebop://files/{area}",
name="Drone storage listing",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"A directory listing from one of the drone's FTP areas: 'media' for what it recorded, "
"'flightplans' for stored missions, 'logs' for its blackbox and debug tree. The "
"firmware-update channel is deliberately not among them."
),
)
async def files(area: str) -> dict:
try:
resolved = ftp.resolve_area(area)
host = drone_host(settings)
except (ftp.FtpError, ToolError) as exc:
return _unavailable(str(exc), area=area)
try:
entries = await _listing(resolved.name, host)
except (ftp.FtpError, OSError) as exc:
return _unavailable(str(exc), area=area)
return {
"available": True,
"area": resolved.name,
"describes": resolved.describe,
"entries": entries,
}
@mcp.resource(
"bebop://log/{tag}",
name="System log by tag",
mime_type=JSON,
annotations={"readOnlyHint": True},
description=(
"The most recent entries the drone's system log carries under one tag, newest first. "
"KERNEL is Linux, COMMANDS is the drone's account of the ARSDK traffic it handled, NETWORK "
"and NETMON are the link, colibry and SETTINGS are the flight config. Use read_log for "
"filtering by severity or message text."
),
)
async def log(tag: str) -> dict:
try:
dest, size, records, stats = await logs.fetch_and_parse(settings)
except (ftp.FtpError, ToolError, OSError) as exc:
return _unavailable(str(exc), tag=tag)
kept, matched = logs.select(records, tag=tag, limit=LOG_RESOURCE_LIMIT)
return {
"available": True,
"tag": tag,
"path": str(dest),
"size": size,
"parsed": stats.entries,
"matched": matched,
"tags": dict(sorted(stats.tags.items(), key=lambda kv: -kv[1])),
"entries": [logs.to_entry(r).model_dump() for r in reversed(kept)],
}
async def _listing(area: str, host: str) -> list[dict]:
entries = await asyncio.to_thread(ftp.list_dir, area, "", host=host)
return [{"name": e.name, "size": e.size, "is_dir": e.is_dir} for e in entries]
+10 -3
View File
@@ -1,4 +1,4 @@
"""Tool registration.
"""Tool and resource registration.
The one place that knows the full set, so naming stays consistent across the
modules that would otherwise each invent their own.
@@ -8,13 +8,20 @@ from fastmcp import FastMCP
from mcbebop.config import Settings
from mcbebop.state import AppState
from mcbebop.tools import camera, command, connection, files, protocol, safety, state
from mcbebop.tools import camera, command, connection, files, logs, protocol, safety, state
from mcbebop.tools._common import set_state
def register_all(mcp: FastMCP, settings: Settings) -> AppState:
app_state = AppState(settings=settings)
set_state(app_state)
for module in (connection, protocol, command, state, camera, files, safety):
for module in (connection, protocol, command, state, camera, files, logs, safety):
module.register(mcp, settings)
# Imported here rather than at module scope: resources reach back into
# `mcbebop.tools` for the shared command and log helpers, and importing it
# at the top would close that loop while this package is still loading.
from mcbebop import resources
resources.register(mcp, settings)
return app_state
+18
View File
@@ -2,7 +2,10 @@
from datetime import UTC, datetime, timedelta
from fastmcp.exceptions import ToolError
from mcbebop import errors
from mcbebop.config import Settings
from mcbebop.state import AppState
_STATE: AppState | None = None
@@ -28,6 +31,21 @@ def require_session():
return state.session
def drone_host(settings: Settings) -> str:
"""Where to reach the aircraft's own servers.
FTP and the debug shell talk to the real machine, so the simulator has
nothing to answer with and saying that plainly beats a connection refused.
"""
state = app()
if state.target == "sim":
raise ToolError(
"Files, logs and the debug shell all talk to the real aircraft; the simulator has no "
"filesystem. Connect to the drone to use this."
)
return state.target or settings.drone_ip
def expiry_iso(seconds_left: float) -> str | None:
if seconds_left <= 0:
return None
+4 -16
View File
@@ -4,28 +4,16 @@ import asyncio
from typing import Annotated, Literal
from fastmcp import Context, FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from mcbebop.config import Settings
from mcbebop.files import ftp, shell
from mcbebop.models import FileEntry
from mcbebop.tools._common import app
from mcbebop.tools._common import drone_host
AreaName = Literal["media", "flightplans", "logs"]
def _host(settings: Settings) -> str:
"""FTP talks to the aircraft directly, so the simulator has nothing to serve."""
state = app()
if state.target == "sim":
raise ToolError(
"File access talks to the real aircraft's FTP server; the simulator has no filesystem. "
"Connect to the drone to use this."
)
return state.target or settings.drone_ip
def register(mcp: FastMCP, settings: Settings) -> None:
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def list_files(
@@ -46,7 +34,7 @@ def register(mcp: FastMCP, settings: Settings) -> None:
Read-only. The firmware-update channel is deliberately not reachable
through this tool.
"""
host = _host(settings)
host = drone_host(settings)
entries = await asyncio.to_thread(ftp.list_dir, area, path, host=host)
return [FileEntry(name=e.name, size=e.size, is_dir=e.is_dir) for e in entries]
@@ -58,7 +46,7 @@ def register(mcp: FastMCP, settings: Settings) -> None:
max_mb: Annotated[int, Field(description="Refuse anything larger.", ge=1, le=512)] = 32,
) -> dict:
"""Download one file from the drone to this machine, and return where it landed."""
host = _host(settings)
host = drone_host(settings)
dest = await asyncio.to_thread(
ftp.fetch,
area,
@@ -86,6 +74,6 @@ def register(mcp: FastMCP, settings: Settings) -> None:
takes an allow-list of command names rather than trying to filter a
free-form line.
"""
host = _host(settings)
host = drone_host(settings)
result = await asyncio.to_thread(shell.run, command, list(args), host=host)
return {"command": result.command, "stdout": result.stdout}
+238
View File
@@ -0,0 +1,238 @@
"""Reading the drone's own system log.
The aircraft logs far more about itself than it reports over ARSDK. The log is
where it says which board it is, which config it read, why a command was
refused and what its Wi-Fi driver did. None of that reaches telemetry.
It is also big: half an hour of uptime is over five thousand entries, which is
why everything here filters before it returns and caps what it returns even
then.
Only the live `ckcm/ckcm.bin` is reachable from here, and that is deliberate.
`system.conf` caps it at a megabyte, after which the drone rolls it into
`Debug/archive/debug_NN.tar.lzo`. Those archives are previous *sessions*, and
on a second-hand aircraft that means a previous owner's flights: network names
and positions that nobody consented to hand to a language model. Reading them
would be a decision for a person with the drone on a bench, not something a
tool does on the way to answering a question.
"""
import asyncio
import logging
import re
from pathlib import Path
from typing import Annotated, Literal
from fastmcp import Context, FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from mcbebop.config import Settings
from mcbebop.files import ftp, ulog
from mcbebop.models import LogEntry, LogRead
from mcbebop.tools._common import drone_host
logger = logging.getLogger(__name__)
#: The log lives in the blackbox tree, which `ftp.AREAS["logs"]` is scoped to.
LOG_AREA = "logs"
LOG_PATH = "ckcm/ckcm.bin"
#: The drone rotates the live log at a megabyte, so this has headroom and
#: still refuses anything the size of a video.
MAX_BYTES = 16 * 1024 * 1024
#: A five-second USB-role poll that says the same thing every time and can be
#: a third of the log. Excluded by default, named in the schema so the caller
#: can see it is being excluded and ask for it back.
NOISY_TAGS = ("shp_usbmode",)
Level = Literal["any", "critical", "error", "warning", "info", "debug"]
_LEVEL_LETTERS = {name: letter for letter, name in ulog.PRIORITY_NAMES.items()}
async def fetch_and_parse(
settings: Settings, path: str = LOG_PATH
) -> tuple[Path, int, list, ulog.ParseStats]:
"""Pull the log off the aircraft and parse it. Shared with the resources."""
host = drone_host(settings)
dest = await asyncio.to_thread(
ftp.fetch,
LOG_AREA,
path,
host=host,
capture_dir=settings.capture_dir,
max_bytes=MAX_BYTES,
)
data = await asyncio.to_thread(dest.read_bytes)
stats = ulog.ParseStats()
records = list(ulog.parse(data, stats))
return dest, len(data), records, stats
def select(
records: list,
*,
tag: str = "",
exclude_tags: tuple[str, ...] | list[str] = (),
match: str = "",
min_level: str = "any",
limit: int = 100,
) -> tuple[list, int]:
"""Filter, then keep the most recent `limit`. Returns `(kept, matched)`.
The limit always takes from the newest end whatever the display order,
because on a log the recent entries are the ones you came for: asking for
fifty lines about a failure and getting fifty lines of kernel boot would
be an unhelpful reading of "the first fifty".
An explicit `tag` beats `exclude_tags`. Asking for a tag by name and
getting nothing because a default excluded it would be indefensible.
"""
pattern = None
if match:
try:
pattern = re.compile(match, re.IGNORECASE)
except re.error as exc:
raise ToolError(
f"`match` is read as a regular expression and {match!r} is not one ({exc}). "
"Plain text works as-is; escape any of . * + ? [ ] ( ) | \\ you meant literally."
) from exc
want_rank = ulog.rank(_LEVEL_LETTERS[min_level]) if min_level != "any" else ulog.UNKNOWN_RANK
tag_lower = tag.lower()
excluded = set() if tag else {t.lower() for t in exclude_tags}
kept = []
for record in records:
lower = record.tag.lower()
if tag and lower != tag_lower:
continue
if lower in excluded:
continue
if min_level != "any" and ulog.rank(record.priority) > want_rank:
continue
if pattern is not None and not pattern.search(record.message):
continue
kept.append(record)
return kept[-limit:] if limit else kept, len(kept)
def to_entry(record) -> LogEntry:
return LogEntry(
uptime=round(record.uptime, 3),
level=record.level,
tag=record.tag,
source=record.source,
message=record.message,
)
def register(mcp: FastMCP, settings: Settings) -> None:
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def read_log(
ctx: Context,
tag: Annotated[
str,
Field(
description=(
"Keep only this tag, exact and case-insensitive. KERNEL is the Linux log, "
"COMMANDS is the drone's own account of the ARSDK traffic it handled, NETWORK "
"and NETMON are the link, SETTINGS and colibry are the flight config. "
"Empty keeps every tag; the result lists them all with counts either way."
)
),
] = "",
exclude_tags: Annotated[
list[str],
Field(
description=(
f"Tags to drop, case-insensitive. Defaults to {list(NOISY_TAGS)}, a five-second "
"USB-role poll that repeats itself and can be a third of the log. Pass an empty "
"list for everything. Ignored when `tag` names a tag explicitly."
)
),
] = list(NOISY_TAGS), # noqa: B006 - read into the schema by FastMCP; never mutated
match: Annotated[
str,
Field(
description=(
"Regular expression the message must contain, case-insensitive. "
"Plain text works as a substring search."
)
),
] = "",
min_level: Annotated[
Level,
Field(description="Keep this severity and worse. 'error' is the quickest way to find trouble."),
] = "any",
limit: Annotated[
int, Field(description="How many entries to return, counting back from the newest.", ge=1, le=500)
] = 100,
newest_first: Annotated[
bool,
Field(
description="Order of the returned entries. The limit takes from the newest end either way."
),
] = True,
path: Annotated[
str, Field(description="A different file in the log area. The default is the system log.")
] = LOG_PATH,
) -> LogRead:
"""Read the drone's system log: kernel, Wi-Fi, camera, flight config, command handling.
Fetches the whole log over FTP and parses Parrot's ulogcat framing, so
it needs the aircraft reachable but no debug shell and no button press.
Reach for it when telemetry is not enough to explain something: a
refused command, a link that dropped, a sensor that failed its
self-test, or which hardware variant this airframe actually is.
There is no wall-clock time in the log. Entries are stamped with
seconds since the drone booted, and the drone does not know the date
unless a phone has told it.
The log is live and the drone appends to it while this runs, so the
newest entry is whatever had been written when the fetch landed. It is
also only the current file: the drone rotates it at a megabyte and the
archives it rolls into are not read from here.
Under `COMMANDS` the drone names things the way its own firmware does,
which is close to Parrot's XML names without matching them, so look a
name up with list_commands() rather than assuming the log's spelling.
"""
dest, size, records, stats = await fetch_and_parse(settings, path)
kept, matched = select(
records,
tag=tag,
exclude_tags=exclude_tags,
match=match,
min_level=min_level,
limit=limit,
)
entries = [to_entry(r) for r in (reversed(kept) if newest_first else kept)]
notes = []
if stats.truncated:
notes.append("the fetch landed mid-record, which is normal while the drone is writing")
if stats.resyncs:
notes.append(f"{stats.resyncs} frames did not decode and were skipped")
if not tag:
present = {k.lower() for k in stats.tags}
dropped = [t for t in exclude_tags if t.lower() in present]
if dropped:
notes.append(f"excluded: {', '.join(dropped)} (pass exclude_tags=[] to see them)")
if tag and not matched:
known = ", ".join(sorted(stats.tags)[:12])
notes.append(f"no entry carries the tag {tag!r}; present tags include {known}")
return LogRead(
path=str(dest),
size=size,
parsed=stats.entries,
matched=matched,
returned=len(entries),
newest_first=newest_first,
tags=dict(sorted(stats.tags.items(), key=lambda kv: -kv[1])),
note="; ".join(notes),
entries=entries,
)
+26 -27
View File
@@ -11,7 +11,7 @@ from mcbebop.models import ArgumentInfo, CommandDetail, CommandSummary
from mcbebop.protocol import xml_index
def _summary(spec) -> CommandSummary:
def summary(spec) -> CommandSummary:
return CommandSummary(
name=spec.full_name,
title=spec.title,
@@ -22,6 +22,29 @@ def _summary(spec) -> CommandSummary:
)
def detail(spec) -> CommandDetail:
"""Everything the XML says about one command, expectations resolved to names."""
confirmed = []
for exp in spec.expectations:
other = xml_index.by_ids(exp.ids)
label = other.full_name if other else "-".join(str(i) for i in exp.ids)
if exp.fields:
label += " where " + ", ".join(f"{k}={v}" for k, v in exp.fields.items())
confirmed.append(label)
return CommandDetail(
**summary(spec).model_dump(),
ids=list(spec.ids),
doc=spec.doc,
buffer=str(spec.buffer),
confirmed_by=confirmed,
args=[
ArgumentInfo(name=a.name, type=a.type, doc=a.doc, enum_values=[m.name for m in a.members])
for a in spec.args
],
)
def register(mcp: FastMCP, settings: Settings) -> None:
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
async def list_commands(
@@ -53,7 +76,7 @@ def register(mcp: FastMCP, settings: Settings) -> None:
continue
if bebop2_only and xml_index.supports_bebop2(spec.support) is False:
continue
out.append(_summary(spec))
out.append(summary(spec))
return sorted(out, key=lambda c: c.name)
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
@@ -66,28 +89,4 @@ def register(mcp: FastMCP, settings: Settings) -> None:
if spec is None:
near = [c.full_name for c in xml_index.search(name)][:5]
raise errors.unknown_command(name, near)
confirmed = []
for exp in spec.expectations:
other = xml_index.by_ids(exp.ids)
label = other.full_name if other else "-".join(str(i) for i in exp.ids)
if exp.fields:
label += " where " + ", ".join(f"{k}={v}" for k, v in exp.fields.items())
confirmed.append(label)
return CommandDetail(
**_summary(spec).model_dump(),
ids=list(spec.ids),
doc=spec.doc,
buffer=str(spec.buffer),
confirmed_by=confirmed,
args=[
ArgumentInfo(
name=a.name,
type=a.type,
doc=a.doc,
enum_values=[m.name for m in a.members],
)
for a in spec.args
],
)
return detail(spec)
+159
View File
@@ -0,0 +1,159 @@
"""`read_log`: the filters, the defaults, and what it says about a live log.
The log is built here from the ulogcat format rather than fetched, so none of
this touches the aircraft or the captured file.
"""
import pytest
from fastmcp import Client
from test_ulog import entry
from mcbebop.config import Settings
from mcbebop.server import build_server
from mcbebop.tools import _common, logs
def synthetic_log() -> bytes:
return b"".join(
[
entry("Booting Linux on physical CPU 0", tag="KERNEL", uptime_us=0),
entry("AM I HOST mode for 5 s", tag="shp_usbmode", uptime_us=1_000_000),
entry("AM I HOST mode for 5 s", tag="shp_usbmode", uptime_us=6_000_000),
entry("Cmd Tx : battery level <62%>", tag="COMMANDS", uptime_us=10_000_000),
entry("Magneto calibration state (1-required) : 0", tag="COMMANDS", uptime_us=11_000_000),
entry("eth0: no IPv6 routers present", tag="KERNEL", priority="W", uptime_us=12_000_000),
entry("fileOpen: error: cannot open file", tag="colibry", priority="E", uptime_us=13_000_000),
]
)
def records():
from mcbebop.files import ulog
return list(ulog.parse(synthetic_log()))
# --- the filters, without a server ------------------------------------------
def test_a_tag_filter_is_exact_and_case_insensitive():
kept, matched = logs.select(records(), tag="kernel")
assert matched == 2
assert {r.tag for r in kept} == {"KERNEL"}
def test_a_substring_is_a_valid_regex_and_behaves_like_one():
kept, _ = logs.select(records(), match="battery")
assert [r.tag for r in kept] == ["COMMANDS"]
def test_a_broken_regex_says_it_was_read_as_a_regex():
from fastmcp.exceptions import ToolError
with pytest.raises(ToolError, match="regular expression"):
logs.select(records(), match="unclosed (")
def test_a_severity_filter_keeps_that_level_and_worse():
kept, _ = logs.select(records(), min_level="warning")
assert [r.level for r in kept] == ["warning", "error"]
kept, _ = logs.select(records(), min_level="error")
assert [r.level for r in kept] == ["error"]
def test_the_limit_takes_the_newest_matches_not_the_first():
kept, matched = logs.select(records(), limit=2)
assert matched == 7
assert [r.uptime_us for r in kept] == [12_000_000, 13_000_000]
def test_the_noisy_poll_is_excluded_by_default_and_can_be_asked_for():
assert "shp_usbmode" in logs.NOISY_TAGS
kept, _ = logs.select(records(), exclude_tags=logs.NOISY_TAGS)
assert "shp_usbmode" not in {r.tag for r in kept}
kept, _ = logs.select(records(), exclude_tags=[])
assert "shp_usbmode" in {r.tag for r in kept}
def test_naming_a_tag_explicitly_beats_the_default_exclusion():
# Asking for a tag by name and getting nothing because a default hid it
# would be the worst kind of helpful.
kept, matched = logs.select(records(), tag="shp_usbmode", exclude_tags=logs.NOISY_TAGS)
assert matched == 2
assert {r.tag for r in kept} == {"shp_usbmode"}
# --- through the server -----------------------------------------------------
@pytest.fixture
def served(monkeypatch, tmp_path):
"""A server whose FTP fetch hands back a synthetic log."""
log = tmp_path / "ckcm.bin"
log.write_bytes(synthetic_log())
asked: dict = {}
def fake_fetch(area, path, *, host, capture_dir, max_bytes=0, timeout=0.0):
asked.update(area=area, path=path, host=host)
return log
monkeypatch.setattr("mcbebop.files.ftp.fetch", fake_fetch)
return build_server(Settings(capture_dir=tmp_path)), asked
@pytest.fixture
async def client(served):
server, asked = served
async with Client(server) as c:
# No connect(): the log comes off the aircraft's FTP server, which
# answers whether or not a command session is open.
_common.app().target = "192.0.2.1"
yield c, asked
async def test_read_log_fetches_from_the_log_area_and_returns_parsed_entries(client):
c, asked = client
result = await c.call_tool("read_log", {})
body = result.structured_content
assert asked == {"area": logs.LOG_AREA, "path": logs.LOG_PATH, "host": "192.0.2.1"}
assert body["parsed"] == 7
assert body["returned"] == 5, "the usb poll should be excluded by default"
assert body["entries"][0]["message"].startswith("fileOpen"), "newest first by default"
assert body["tags"]["KERNEL"] == 2
async def test_read_log_reports_which_tags_it_hid(client):
c, _ = client
body = (await c.call_tool("read_log", {})).structured_content
assert "shp_usbmode" in body["note"]
assert "exclude_tags=[]" in body["note"]
async def test_read_log_can_order_oldest_first_while_still_taking_the_newest(client):
c, _ = client
body = (await c.call_tool("read_log", {"limit": 2, "newest_first": False})).structured_content
assert [e["uptime"] for e in body["entries"]] == [12.0, 13.0]
async def test_the_whole_file_tag_census_survives_the_filters(client):
c, _ = client
body = (await c.call_tool("read_log", {"tag": "colibry"})).structured_content
assert body["matched"] == 1
assert body["parsed"] == 7, "parsed counts the file, not the filter"
assert set(body["tags"]) == {"KERNEL", "shp_usbmode", "COMMANDS", "colibry"}
async def test_an_absent_tag_says_which_tags_are_present(client):
c, _ = client
body = (await c.call_tool("read_log", {"tag": "NETMON"})).structured_content
assert body["returned"] == 0
assert "NETMON" in body["note"] and "KERNEL" in body["note"]
async def test_the_log_is_not_reachable_on_the_simulator(client):
from fastmcp.exceptions import ToolError
c, _ = client
_common.app().target = "sim"
with pytest.raises(ToolError, match="simulator"):
await c.call_tool("read_log", {})
+198
View File
@@ -0,0 +1,198 @@
"""The MCP resources, driven through a real client.
The point of the split under test here is that the command catalogue needs no
aircraft while everything else does, and that the ones that do degrade into an
explanation rather than an exception.
"""
import json
import pytest
from fastmcp import Client
from test_ulog import entry
from mcbebop.config import Settings
from mcbebop.protocol import xml_index
from mcbebop.server import build_server
from mcbebop.tools import _common, logs
def body(result) -> dict:
return json.loads(result[0].text)
@pytest.fixture
async def client():
async with Client(build_server()) as c:
yield c
@pytest.fixture
async def connected(client):
await client.call_tool("connect", {"target": "sim"})
yield client
await client.call_tool("disconnect", {})
# --- what is registered ------------------------------------------------------
async def test_the_static_resources_are_registered(client):
uris = {str(r.uri) for r in await client.list_resources()}
assert uris == {"bebop://commands", "bebop://state"}
async def test_the_templated_resources_are_registered(client):
uris = {t.uri_template for t in await client.list_resource_templates()}
assert uris == {
"bebop://commands/{name}",
"bebop://state/{key}",
"bebop://files/{area}",
"bebop://log/{tag}",
}
# --- the catalogue, which needs no drone -------------------------------------
async def test_the_whole_catalogue_reads_without_a_drone(client):
data = body(await client.read_resource("bebop://commands"))
assert data["count"] == len(xml_index.all_commands())
names = {c["name"] for c in data["commands"]}
assert "ardrone3.Piloting.TakeOff" in names
assert all({"tier", "direction", "bebop2"} <= set(c) for c in data["commands"])
async def test_one_command_reads_in_full_with_its_dotted_name(client):
data = body(await client.read_resource("bebop://commands/ardrone3.Camera.OrientationV2"))
assert data["name"] == "ardrone3.Camera.OrientationV2"
assert [a["name"] for a in data["args"]] == ["tilt", "pan"]
assert data["ids"] and data["buffer"]
async def test_an_enum_argument_arrives_with_its_allowed_names(client):
# Without these a caller has to guess at the string an argument accepts,
# which is the main reason to read a command rather than send and see.
data = body(await client.read_resource("bebop://commands/common.Mavlink.Start"))
(arg,) = [a for a in data["args"] if a["name"] == "type"]
assert arg["enum_values"] == ["flightPlan", "mapMyHouse"]
async def test_an_unknown_command_suggests_rather_than_failing(client):
data = body(await client.read_resource("bebop://commands/ardrone3.Piloting.Takeof"))
assert data["available"] is False
assert "ardrone3.Piloting.TakeOff" in data["did_you_mean"]
# --- the ones that need a drone ----------------------------------------------
async def test_state_explains_itself_when_nothing_is_connected(client):
data = body(await client.read_resource("bebop://state"))
assert data["available"] is False
assert "connect" in data["reason"].lower()
async def test_one_state_key_explains_itself_when_nothing_is_connected(client):
data = body(await client.read_resource("bebop://state/BatteryStateChanged_percent"))
assert data["available"] is False
async def test_state_carries_every_reported_value_with_its_age(connected):
data = body(await connected.read_resource("bebop://state"))
assert data["available"] is True
assert data["keys"] > 20
battery = data["values"]["BatteryStateChanged_percent"]
assert isinstance(battery["value"], int) and battery["age"] >= 0
async def test_state_reports_link_freshness_because_connected_is_not_evidence(connected):
# The aircraft accepts a second controller and silently redirects
# telemetry to it, leaving this session reporting connected with nothing
# arriving. The age of the newest event is the only tell.
data = body(await connected.read_resource("bebop://state"))
assert data["last_event_age"] is not None and data["last_event_age"] < 10
async def test_a_state_key_matches_on_a_prefix(connected):
data = body(await connected.read_resource("bebop://state/BatteryStateChanged"))
assert data["available"] is True
assert "BatteryStateChanged_percent" in data["values"]
async def test_a_state_key_the_drone_never_sent_says_so(connected):
data = body(await connected.read_resource("bebop://state/NoSuchThing"))
assert data["available"] is False
assert "bebop://state" in data["reason"]
async def test_a_file_listing_on_the_simulator_explains_itself(connected):
data = body(await connected.read_resource("bebop://files/media"))
assert data["available"] is False
assert "simulator" in data["reason"]
async def test_an_unknown_file_area_lists_the_real_ones(client):
data = body(await client.read_resource("bebop://files/update"))
assert data["available"] is False
assert "flightplans" in data["reason"]
async def test_the_log_resource_explains_itself_with_no_aircraft(connected):
data = body(await connected.read_resource("bebop://log/KERNEL"))
assert data["available"] is False
assert "simulator" in data["reason"]
# --- the log resource against a synthetic log --------------------------------
@pytest.fixture
async def with_log(monkeypatch, tmp_path):
log = tmp_path / "ckcm.bin"
log.write_bytes(
entry("Machine: Milos board", tag="KERNEL", uptime_us=0)
+ entry("Cmd Tx : battery level <62%>", tag="COMMANDS", uptime_us=9_000_000)
+ entry("eth0: no IPv6 routers present", tag="KERNEL", priority="W", uptime_us=12_000_000)
)
monkeypatch.setattr("mcbebop.files.ftp.fetch", lambda *a, **k: log)
async with Client(build_server(Settings(capture_dir=tmp_path))) as c:
_common.app().target = "192.0.2.1"
yield c
async def test_the_log_resource_returns_recent_entries_for_one_tag(with_log):
data = body(await with_log.read_resource("bebop://log/KERNEL"))
assert data["available"] is True
assert data["matched"] == 2
assert data["parsed"] == 3
assert data["entries"][0]["message"].startswith("eth0"), "newest first"
async def test_the_log_resource_shows_the_whole_tag_census(with_log):
data = body(await with_log.read_resource("bebop://log/COMMANDS"))
assert data["tags"] == {"KERNEL": 2, "COMMANDS": 1}
async def test_the_log_resource_caps_what_it_returns(with_log):
from mcbebop import resources
assert resources.LOG_RESOURCE_LIMIT <= 100, "a resource read must not flood a context"
data = body(await with_log.read_resource("bebop://log/KERNEL"))
assert len(data["entries"]) <= resources.LOG_RESOURCE_LIMIT
async def test_a_tag_the_log_does_not_carry_is_empty_not_an_error(with_log):
data = body(await with_log.read_resource("bebop://log/NETMON"))
assert data["available"] is True
assert data["matched"] == 0
assert data["entries"] == []
async def test_the_log_resource_does_not_apply_the_tools_default_exclusions(with_log):
# It addresses one tag by name, so there is nothing to protect the caller
# from; a resource that silently returned nothing for a tag it was asked
# for would be worse than a long answer.
assert logs.NOISY_TAGS, "the tool excludes something by default"
data = body(await with_log.read_resource("bebop://log/KERNEL"))
assert data["matched"] == 2
+1 -1
View File
@@ -32,7 +32,7 @@ async def test_every_tool_is_registered(client):
assert names == {
"connect", "disconnect", "connection_status", "list_commands", "command_info",
"send_command", "get_state", "watch_state", "preflight_check", "camera_snapshot",
"camera_record", "list_files", "fetch_file", "shell_read", "arm", "disarm",
"camera_record", "list_files", "fetch_file", "shell_read", "read_log", "arm", "disarm",
} # fmt: skip