Files
mcbebop/src/mcbebop/tools/connection.py
T
rsp2k ae2cf54e68 Stream video from the simulator, gated on VideoEnable as the drone gates it
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.
2026-10-02 12:07:26 -06:00

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()