The sim takes an optional video source. Nothing flows until ardrone3.MediaStreaming.VideoEnable arrives with 1; RTP then goes from the sim's own 5004 to whatever arstream2_client_stream_port the controller named in its handshake, and stops on a 0, on a link loss, or at shutdown. The MediaStreamingState.VideoEnableChanged reply goes out either way, because that reports the aircraft's state and not its bitrate, so a client waiting on the confirmation must not hang for want of a file to stream. Video stays optional: a FakeBebop() with no source gains no thread and sends no packets. One source object for the life of the sim, so sequence numbers and timestamps keep advancing across both the loop point in the file and a disable/enable cycle. A decoder handed a timestamp that went backwards treats the stream as corrupt and stays that way. The test that proves any of this works is the ffmpeg decode: frames come out at 856x480, including when ffmpeg joins a stream that has already been running for two seconds, which is the case a goggle viewer actually faces. The clip is generated at setup and never committed. Also fixed stop() on a sim that was never started: the sockets bind in __post_init__, so it had ports to release, and joining an unstarted thread raised and left them held.
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""Opening and closing the link to the aircraft."""
|
|
|
|
import asyncio
|
|
from typing import Annotated
|
|
|
|
from fastmcp import Context, FastMCP
|
|
from pydantic import Field
|
|
|
|
from mcbebop import errors
|
|
from mcbebop.config import Settings
|
|
from mcbebop.models import ConnectionInfo
|
|
from mcbebop.state import SIM_TARGET
|
|
from mcbebop.tools._common import app, expiry_iso
|
|
|
|
|
|
def _info() -> ConnectionInfo:
|
|
state = app()
|
|
session = state.session
|
|
stats = session.link_stats() if state.connected else {}
|
|
return ConnectionInfo(
|
|
connected=state.connected,
|
|
target=state.target or "none",
|
|
armed=state.arming.armed,
|
|
armed_until=expiry_iso(state.arming.seconds_left),
|
|
telemetry_keys=stats.get("telemetry_keys", 0) or 0,
|
|
last_update_age=stats.get("last_event_age"),
|
|
undecodable_events=stats.get("undecodable_events", 0) or 0,
|
|
)
|
|
|
|
|
|
def register(mcp: FastMCP, settings: Settings) -> None:
|
|
@mcp.tool(annotations={"openWorldHint": True})
|
|
async def connect(
|
|
ctx: Context,
|
|
target: Annotated[
|
|
str,
|
|
Field(
|
|
description=(
|
|
"Where to connect: an IP address, 'drone' for the configured address "
|
|
f"({settings.drone_ip}), or 'sim' to run a built-in simulator instead of the "
|
|
"aircraft. Use 'sim' to rehearse anything that would move a real drone."
|
|
)
|
|
),
|
|
] = "drone",
|
|
) -> ConnectionInfo:
|
|
"""Open a session with the drone. Nothing else works until this succeeds.
|
|
|
|
This machine must already have joined the drone's own Wi-Fi network.
|
|
|
|
Connecting while something else is already controlling the drone takes
|
|
the link over rather than failing: the aircraft accepts the newcomer and
|
|
stops sending telemetry to whoever had it, without telling them. So if a
|
|
phone app is flying it, connect here and the phone goes quiet. Watch
|
|
last_update_age rather than the connected flag to tell a live link from
|
|
one that has been taken.
|
|
"""
|
|
state = app()
|
|
async with state.lock:
|
|
if state.connected:
|
|
raise errors.already_connected(state.target)
|
|
|
|
from mcbebop.arsdk.session import DroneSession
|
|
from mcbebop.arsdk.types import HandshakeError
|
|
|
|
if target == SIM_TARGET:
|
|
from mcbebop.sim import FakeBebop
|
|
|
|
sim = FakeBebop(video_source=settings.sim_video_source)
|
|
sim.__enter__()
|
|
state.sim = sim
|
|
session = DroneSession(ip=sim.host, discovery_port=sim.discovery_port)
|
|
else:
|
|
ip = settings.drone_ip if target == "drone" else target
|
|
session = DroneSession(ip=ip)
|
|
|
|
try:
|
|
await session.connect()
|
|
except HandshakeError as exc:
|
|
await state.close()
|
|
status = getattr(exc, "status", None)
|
|
raise errors.handshake_refused(status) if status else errors.not_connected() from exc
|
|
except OSError as exc:
|
|
await state.close()
|
|
raise errors.not_connected() from exc
|
|
|
|
state.session = session
|
|
state.target = target if target == SIM_TARGET else getattr(session, "ip", target)
|
|
await session.request_full_state()
|
|
# The drone answers the state request as a burst over the next
|
|
# moment, so pause before reporting a count: returning zero keys
|
|
# from a healthy connection reads as a failure.
|
|
await asyncio.sleep(1.0)
|
|
return _info()
|
|
|
|
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False})
|
|
async def connection_status(ctx: Context) -> ConnectionInfo:
|
|
"""Whether a session is open, how fresh its telemetry is, and whether it is armed."""
|
|
return _info()
|
|
|
|
@mcp.tool(annotations={"openWorldHint": False})
|
|
async def disconnect(ctx: Context) -> ConnectionInfo:
|
|
"""Close the session. Also disarms, and stops the drone's video if it was started."""
|
|
state = app()
|
|
async with state.lock:
|
|
await state.close()
|
|
return _info()
|