Scaffold: package, settings, errors, server factory, shared contracts
protocol/types.py and arsdk/types.py are the interfaces the parallel streams build against: command/arg/enum/expectation specs and the safety tiers on one side, frame encoding and the buffer conventions on the other. Logging goes to stderr throughout, since stdout carries JSON-RPC. That is also why this package will speak ARSDK itself rather than through pyparrot, which prints from its receive thread.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
captures/
|
||||||
|
*.egg-info/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# mcbebop
|
||||||
|
|
||||||
|
An MCP server for the Parrot Bebop 2, so an agent can talk to the drone without
|
||||||
|
anyone writing code for each question.
|
||||||
|
|
||||||
|
Parrot abandoned the Bebop line, but the aircraft is still a capable, cheap
|
||||||
|
platform and its protocol is fully described by Parrot's own XML, which this
|
||||||
|
package vendors. Every one of the **264 commands** is reachable; nothing is
|
||||||
|
hand-wrapped.
|
||||||
|
|
||||||
|
> **Status: in development.** Not flight-tested. See the safety section.
|
||||||
|
|
||||||
|
## What it talks to
|
||||||
|
|
||||||
|
| Interface | Notes |
|
||||||
|
|---|---|
|
||||||
|
| ARSDK3 over UDP | commands and telemetry; this package implements the protocol directly |
|
||||||
|
| ARStream2 | live H.264 video. The Bebop 2 serves no RTSP, despite what older libraries assume |
|
||||||
|
| FTP | media, flight plans, blackbox logs |
|
||||||
|
| Telnet | read-only shell, only after the drone's debug mode is enabled |
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
Commands are classified by consequence. Observing and configuring are open;
|
||||||
|
anything that can spin a motor or change the flight envelope refuses until
|
||||||
|
`arm()` is called with a reason, and re-locks on a timer and on disconnect.
|
||||||
|
`Landing` and `Emergency` are deliberately never locked, because refusing to
|
||||||
|
land an airborne aircraft is the more dangerous answer.
|
||||||
|
|
||||||
|
`connect(target="sim")` runs everything against a protocol-accurate simulator,
|
||||||
|
which is where anything involving motion should be rehearsed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx mcbebop
|
||||||
|
claude mcp add mcbebop -- uvx mcbebop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
`arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause. See
|
||||||
|
`arsdk-xml/PROVENANCE.md`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
[project]
|
||||||
|
name = "mcbebop"
|
||||||
|
version = "2026.10.02"
|
||||||
|
description = "MCP server for the Parrot Bebop 2 drone: telemetry, camera, files, and every ARSDK command"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||||
|
keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Scientific/Engineering",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
# 4.x is the current family; <5 because FastMCP has moved import paths
|
||||||
|
# across majors before (mcvnc was broken by the 3 -> 4 move).
|
||||||
|
"fastmcp>=4.0.10,<5",
|
||||||
|
"pydantic-settings>=2.7",
|
||||||
|
"zeroconf>=0.147", # mDNS discovery of _arsdk-090c._udp
|
||||||
|
"pillow>=11.0", # downscale camera frames before they reach the model
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://warehack.ing"
|
||||||
|
Repository = "https://git.supported.systems/rsp2k/mcbebop"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcbebop = "mcbebop.server:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.11.3,<0.12.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
source-exclude = ["CLAUDE.md", ".env", ".env.*", ".mcp.json", "tests", "captures"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
markers = ["drone: needs a real Bebop 2 on the network (run with '-m drone')"]
|
||||||
|
addopts = "-m 'not drone'"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 110
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""MCP server for the Parrot Bebop 2."""
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("mcbebop")
|
||||||
|
except PackageNotFoundError: # running from a source tree without an install
|
||||||
|
__version__ = "0.0.0"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Wire-level vocabulary for the ARSDK3 client.
|
||||||
|
|
||||||
|
Stage-0 contract. The constants are what the live Bebop 2 actually uses,
|
||||||
|
recorded in bebop-2's `docs/notes/protocol.md` and confirmed against firmware
|
||||||
|
4.7.1 on 2026-10-01.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
DEFAULT_IP = "192.168.42.1"
|
||||||
|
DISCOVERY_PORT = 44444 # TCP, the JSON handshake
|
||||||
|
MDNS_SERVICE = "_arsdk-090c._udp.local."
|
||||||
|
|
||||||
|
# Ports we tell the drone to send to, and that we therefore bind.
|
||||||
|
D2C_PORT = 43210 # telemetry
|
||||||
|
VIDEO_STREAM_PORT = 55004 # ARStream2 RTP (H.264)
|
||||||
|
VIDEO_CONTROL_PORT = 55005 # ARStream2 RTCP
|
||||||
|
|
||||||
|
# The header on every frame: data type, buffer id, sequence, total size
|
||||||
|
# INCLUDING these 7 bytes.
|
||||||
|
FRAME_HEADER = struct.Struct("<BBBI")
|
||||||
|
# Then the command: project (u8), class (u8), command (u16 -- not u8).
|
||||||
|
COMMAND_HEADER = struct.Struct("<BBH")
|
||||||
|
|
||||||
|
|
||||||
|
class DataType(enum.IntEnum):
|
||||||
|
ACK = 1
|
||||||
|
DATA = 2 # no acknowledgement expected
|
||||||
|
LOW_LATENCY = 3
|
||||||
|
DATA_WITH_ACK = 4
|
||||||
|
|
||||||
|
|
||||||
|
class BufferId(enum.IntEnum):
|
||||||
|
"""Buffer ids are a fixed convention, not negotiated."""
|
||||||
|
|
||||||
|
PING = 0 # drone -> us
|
||||||
|
PONG = 1 # our reply
|
||||||
|
C2D_NON_ACK = 10 # piloting and camera: fire and forget
|
||||||
|
C2D_ACK = 11 # everything else
|
||||||
|
C2D_HIGH_PRIO = 12 # emergency
|
||||||
|
C2D_VIDEO_ACK = 13
|
||||||
|
D2C_VIDEO = 125
|
||||||
|
D2C_NON_ACK = 126
|
||||||
|
D2C_ACK = 127 # we must acknowledge these
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ack_for(buffer_id: int) -> int:
|
||||||
|
"""The buffer an acknowledgement travels on."""
|
||||||
|
return (buffer_id + 128) % 256
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Frame:
|
||||||
|
data_type: DataType
|
||||||
|
buffer_id: int
|
||||||
|
seq: int
|
||||||
|
payload: bytes
|
||||||
|
|
||||||
|
def encode(self) -> bytes:
|
||||||
|
size = FRAME_HEADER.size + len(self.payload)
|
||||||
|
return FRAME_HEADER.pack(int(self.data_type), self.buffer_id, self.seq, size) + self.payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_all(data: bytes) -> list[Frame]:
|
||||||
|
"""One datagram can carry several frames back to back.
|
||||||
|
|
||||||
|
A frame claiming a size below the header length would never advance the
|
||||||
|
cursor, so it ends the parse rather than looping forever.
|
||||||
|
"""
|
||||||
|
out: list[Frame] = []
|
||||||
|
while len(data) >= FRAME_HEADER.size:
|
||||||
|
dt, buf, seq, size = FRAME_HEADER.unpack_from(data)
|
||||||
|
if size < FRAME_HEADER.size or size > len(data):
|
||||||
|
break
|
||||||
|
out.append(Frame(DataType(dt), buf, seq, data[FRAME_HEADER.size : size]))
|
||||||
|
data = data[size:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Event:
|
||||||
|
"""A decoded drone-to-controller message."""
|
||||||
|
|
||||||
|
ids: tuple[int, int, int]
|
||||||
|
name: str # the command name, e.g. "BatteryStateChanged"
|
||||||
|
values: dict[str, Any] # keyed `<Command>_<arg>`, as our captures are
|
||||||
|
at: float # time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
class HandshakeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotConnected(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Protocol):
|
||||||
|
"""What `arsdk/session.py` exposes to the tools layer."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool: ...
|
||||||
|
|
||||||
|
async def send(self, spec: Any, args: dict[str, Any], *, confirm: bool = True) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def state(self, keys: list[str] | None = None) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def subscribe(self, fn: Any) -> Any: ...
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Settings, from the environment with an `MCBEBOP_` prefix."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="MCBEBOP_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
drone_ip: str = Field(default="192.168.42.1", description="Address of the drone's own access point.")
|
||||||
|
connect_timeout: float = Field(default=5.0, description="Seconds to wait for the TCP handshake.")
|
||||||
|
arm_minutes: int = Field(default=5, description="How long arm() keeps motion commands unlocked.")
|
||||||
|
snapshot_max_width: int = Field(
|
||||||
|
default=640, description="Camera frames are downscaled to this width before being returned."
|
||||||
|
)
|
||||||
|
capture_dir: Path = Field(
|
||||||
|
default=Path("captures"), description="Where recordings and snapshots are written."
|
||||||
|
)
|
||||||
|
transport: str = Field(default="stdio", description="stdio or http.")
|
||||||
|
host: str = Field(default="127.0.0.1", description="Bind address when transport is http.")
|
||||||
|
port: int = Field(default=8440, description="Port when transport is http.")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""ToolError factories.
|
||||||
|
|
||||||
|
Each message is written for the agent that will read it, and says what to do
|
||||||
|
next rather than only what went wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
|
||||||
|
def not_connected() -> ToolError:
|
||||||
|
return ToolError(
|
||||||
|
"Not connected to a drone. Call connect() first. If the aircraft is powered on but "
|
||||||
|
"unreachable, check this machine has joined its access point (an SSID like 'Bebop2-XXXXXX')."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def already_connected(target: str) -> ToolError:
|
||||||
|
return ToolError(f"Already connected to {target}. Call disconnect() before connecting elsewhere.")
|
||||||
|
|
||||||
|
|
||||||
|
def handshake_refused(status: int) -> ToolError:
|
||||||
|
return ToolError(
|
||||||
|
f"The drone refused the connection (status {status}). It serves one controller at a time, "
|
||||||
|
"so close FreeFlight on any phone or tablet that is holding the link."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unknown_command(name: str, suggestions: list[str]) -> ToolError:
|
||||||
|
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
|
||||||
|
return ToolError(f"No command named '{name}'. Use list_commands() to search the 264 defined.{hint}")
|
||||||
|
|
||||||
|
|
||||||
|
def not_sendable(name: str) -> ToolError:
|
||||||
|
return ToolError(
|
||||||
|
f"'{name}' is an event the drone emits, not a command you can send. "
|
||||||
|
"Read it with get_state() or watch_state() instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unsupported(name: str, support: str | None) -> ToolError:
|
||||||
|
return ToolError(
|
||||||
|
f"'{name}' does not declare support for the Bebop 2 (product 090c); its support field is "
|
||||||
|
f"{support!r}. Sending it is likely to be ignored. Use command_info() to check."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_arming(name: str, tier: str) -> ToolError:
|
||||||
|
return ToolError(
|
||||||
|
f"'{name}' is a {tier} command: it can move the aircraft or change its flight envelope, so it "
|
||||||
|
"is locked. If that is genuinely what you want, call arm() with a reason first, and make sure "
|
||||||
|
"the propellers are off or the aircraft is somewhere safe to move. To rehearse without risk, "
|
||||||
|
"connect(target='sim') and send it to the simulator."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bad_argument(command: str, detail: str) -> ToolError:
|
||||||
|
return ToolError(f"Cannot encode arguments for '{command}': {detail}. Call command_info('{command}').")
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Shared vocabulary for the protocol layer.
|
||||||
|
|
||||||
|
Stage-0 contract: `protocol/` implements these, `arsdk/` and `tools/` consume
|
||||||
|
them. Changing anything here affects every stream, so changes are announced.
|
||||||
|
|
||||||
|
Everything is derived from Parrot's own XML in `arsdk-xml/`, so the encoder
|
||||||
|
never carries a hand-written table of commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
BEBOP2_PRODUCT_ID = "090c"
|
||||||
|
|
||||||
|
|
||||||
|
class Direction(enum.StrEnum):
|
||||||
|
"""Which way a command travels.
|
||||||
|
|
||||||
|
The XML has no direction attribute. `<comment result=...>` means "this is
|
||||||
|
what happens when you send it" and `triggered=` means "this is when the
|
||||||
|
drone emits it"; no command carries both. Where neither is present, fall
|
||||||
|
back to the class-name suffix (`...State`, `...Event` are events). Note
|
||||||
|
`common.Controller` is a mixed class, so the suffix rule alone is wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TO_DRONE = "to_drone"
|
||||||
|
FROM_DRONE = "from_drone"
|
||||||
|
|
||||||
|
|
||||||
|
class Buffer(enum.StrEnum):
|
||||||
|
"""Which ARNetwork buffer carries the command, from the `buffer` attribute.
|
||||||
|
|
||||||
|
Absent is the common case and means the acknowledged buffer. The values
|
||||||
|
decide both the frame's data type and its buffer id, so this is not
|
||||||
|
cosmetic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACK = "ack" # attribute absent: DATA_WITH_ACK, buffer 11
|
||||||
|
NON_ACK = "non_ack" # buffer="NON_ACK": DATA_NO_ACK, buffer 10
|
||||||
|
HIGH_PRIO = "high_prio" # buffer="HIGH_PRIO": buffer 12. Emergency only.
|
||||||
|
|
||||||
|
|
||||||
|
class Tier(enum.StrEnum):
|
||||||
|
"""What a command can do, which decides whether it needs an unlock.
|
||||||
|
|
||||||
|
Ordered by consequence. `safety.py` assigns these; `tools/command.py`
|
||||||
|
enforces them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
OBSERVE = "observe" # ask the drone to report; changes nothing
|
||||||
|
CONFIG = "config" # camera, media, clock: no effect on flight
|
||||||
|
ENVELOPE = "envelope" # limits, geofence, home point, radio, reboot
|
||||||
|
MOTION = "motion" # can spin motors or move the aircraft
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnumSpec:
|
||||||
|
"""One member of an `<arg type="enum">`.
|
||||||
|
|
||||||
|
`value` is the member's 0-based position among its siblings, which is what
|
||||||
|
goes on the wire. Names are kept verbatim because some are not valid
|
||||||
|
identifiers (`2_4ghz`, `30_FPS`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
value: int
|
||||||
|
doc: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArgSpec:
|
||||||
|
name: str
|
||||||
|
type: str # u8 i8 u16 i16 u32 i32 u64 float double string enum bitfield:<w>:<e>
|
||||||
|
doc: str = ""
|
||||||
|
members: tuple[EnumSpec, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enum(self) -> bool:
|
||||||
|
return self.type == "enum"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_bitfield(self) -> bool:
|
||||||
|
return self.type.startswith("bitfield:")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Expectation:
|
||||||
|
"""A confirming event the drone should emit, parsed from `<expectations>`.
|
||||||
|
|
||||||
|
`fields` maps an event argument to either a literal (an enum member name)
|
||||||
|
or `this.<argname>`, meaning "echoes the value you sent". 82 of the 101
|
||||||
|
sendable commands carry one, so most sends can be confirmed by the drone's
|
||||||
|
own report rather than only by the ack.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ids: tuple[int, int, int]
|
||||||
|
fields: dict[str, str] = field(default_factory=dict)
|
||||||
|
alternatives: tuple[Expectation, ...] = ()
|
||||||
|
delayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandSpec:
|
||||||
|
project: str
|
||||||
|
klass: str
|
||||||
|
name: str
|
||||||
|
ids: tuple[int, int, int] # project, class, command
|
||||||
|
args: tuple[ArgSpec, ...] = ()
|
||||||
|
buffer: Buffer = Buffer.ACK
|
||||||
|
direction: Direction = Direction.TO_DRONE
|
||||||
|
tier: Tier = Tier.CONFIG
|
||||||
|
support: str | None = None
|
||||||
|
deprecated: bool = False
|
||||||
|
title: str = ""
|
||||||
|
doc: str = ""
|
||||||
|
expectations: tuple[Expectation, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
return f"{self.project}.{self.klass}.{self.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_key_prefix(self) -> str:
|
||||||
|
"""Telemetry keys are `<Command>_<arg>`, matching our existing captures."""
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolIndex(Protocol):
|
||||||
|
"""What `xml_index.py` exposes."""
|
||||||
|
|
||||||
|
def get(self, full_name: str) -> CommandSpec | None: ...
|
||||||
|
def by_ids(self, ids: tuple[int, int, int]) -> CommandSpec | None: ...
|
||||||
|
def search(self, text: str) -> list[CommandSpec]: ...
|
||||||
|
def all(self) -> list[CommandSpec]: ...
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Server factory and entry point.
|
||||||
|
|
||||||
|
stdout carries the JSON-RPC transport, so every diagnostic goes to stderr.
|
||||||
|
That constraint is also why this package speaks ARSDK itself rather than
|
||||||
|
through pyparrot, which prints from its receive thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcbebop import __version__
|
||||||
|
from mcbebop.config import Settings
|
||||||
|
|
||||||
|
INSTRUCTIONS = """
|
||||||
|
Tools for a Parrot Bebop 2 drone (firmware 4.7.1, ARSDK3).
|
||||||
|
|
||||||
|
Start with connect(). Nothing else works until a session is open. Use
|
||||||
|
connect(target="sim") to talk to a built-in simulator instead of the aircraft,
|
||||||
|
which is the right place to rehearse anything that would move a real drone.
|
||||||
|
|
||||||
|
Every one of Parrot's 264 commands is reachable through send_command(); browse
|
||||||
|
them with list_commands() and read one with command_info(). Commands are
|
||||||
|
classified by consequence: observe and config are open, while anything that
|
||||||
|
moves the aircraft or changes its flight envelope refuses until arm() is called.
|
||||||
|
|
||||||
|
preflight_check() is the quickest way to learn whether the aircraft is healthy.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_server(settings: Settings | None = None) -> FastMCP:
|
||||||
|
"""Build the server. Tests call this directly so they can inject settings."""
|
||||||
|
settings = settings or Settings()
|
||||||
|
mcp = FastMCP("mcbebop", instructions=INSTRUCTIONS, version=__version__)
|
||||||
|
mcp.settings_obj = settings # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
from mcbebop.tools import register_all
|
||||||
|
|
||||||
|
register_all(mcp, settings)
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stderr,
|
||||||
|
level=os.environ.get("MCBEBOP_LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
settings = Settings()
|
||||||
|
print(f"mcbebop v{__version__} transport={settings.transport} drone={settings.drone_ip}", file=sys.stderr)
|
||||||
|
|
||||||
|
server = build_server(settings)
|
||||||
|
if settings.transport == "http":
|
||||||
|
server.run(
|
||||||
|
transport="http",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
json_response=True,
|
||||||
|
stateless_http=True,
|
||||||
|
show_banner=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
server.run(show_banner=False)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Tool registration.
|
||||||
|
|
||||||
|
Each module exposes `register(mcp, settings)`; this is the only place that
|
||||||
|
knows the full set, so tool naming stays consistent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from mcbebop.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def register_all(mcp: FastMCP, settings: Settings) -> None:
|
||||||
|
# Modules are added here as each stream lands. Stage 0 registers nothing,
|
||||||
|
# so the server starts and answers list_tools with an empty set.
|
||||||
|
modules: list = []
|
||||||
|
for module in modules:
|
||||||
|
module.register(mcp, settings)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Guards that the package stays coherent as the streams land."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import mcbebop
|
||||||
|
from mcbebop.server import build_server
|
||||||
|
|
||||||
|
|
||||||
|
def _pyproject() -> dict:
|
||||||
|
return tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_matches_pyproject():
|
||||||
|
# importlib normalises 2026.10.02 to 2026.10.2; compare as release tuples.
|
||||||
|
declared = _pyproject()["project"]["version"]
|
||||||
|
assert tuple(int(p) for p in declared.split(".")) == tuple(int(p) for p in mcbebop.__version__.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_point_is_importable():
|
||||||
|
module, _, attr = _pyproject()["project"]["scripts"]["mcbebop"].partition(":")
|
||||||
|
mod = __import__(module, fromlist=[attr])
|
||||||
|
assert callable(getattr(mod, attr))
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_builds():
|
||||||
|
assert build_server() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendored_xml_present_and_parses():
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
root = Path(__file__).parent.parent / "arsdk-xml"
|
||||||
|
total = 0
|
||||||
|
for name in ("common.xml", "ardrone3.xml"):
|
||||||
|
proj = ET.parse(root / name).getroot()
|
||||||
|
# Parrot's element is <class>; pyparrot renamed it and we reverted that.
|
||||||
|
assert proj.find("class") is not None, f"{name} has no <class> elements"
|
||||||
|
total += sum(len(c.findall("cmd")) for c in proj.iter("class"))
|
||||||
|
assert total == 264, f"expected 264 commands, found {total}"
|
||||||
Reference in New Issue
Block a user