Ports bebop-2's working video.py, which was verified against the live aircraft. Firmware 4.7.1 serves no RTSP, so there is no URL and no RTSP path here: we describe our own receiving port in an SDP and let ffmpeg bind it before VideoEnable goes out, because RTP is connectionless and packets that arrive before the sink is listening are gone. Three changes on top of the port. StreamSession no longer owns the drone link, since the tools layer holds a long-lived session; it takes an async sender callable instead, which also keeps media/ loadable while arsdk/ and protocol/ are still being written. The context manager is async for the same reason, with the blocking subprocess waits moved off the event loop. And downscale() shrinks a frame before it reaches a model, because a full 856x480 is most of a context window spent on pixels nobody asked for. FTP exposes media (21), flightplans (61) and logs (21, scoped to the Debug tree). Port 51 is deliberately absent: it serves /update as root, it is how firmware is pushed, it has no read use case, and the drone's Wi-Fi is open. Fetches stream to capture_dir under a size cap so a 1080p recording cannot be pulled into a tool result. The shell is an allow-list of eleven read-only command names rather than a deny-list, because the login is `exec /bin/sh -l` with no password and deny-lists on shells leak. Arguments carrying shell metacharacters are refused before the socket opens. Output is bracketed between two echoed nonce markers rather than trimmed by prompt pattern, since telnetd's pty echoes our input with the prompt glued to the front and sends all of it before anything runs.
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Video: SDP text, ffmpeg argv, the downscale helper, and the start ordering.
|
|
|
|
Nothing here spawns ffmpeg or touches a drone.
|
|
"""
|
|
|
|
import io
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from mcbebop.media import video
|
|
|
|
|
|
def test_sdp_describes_our_own_port_not_the_drone():
|
|
# c= must be the wildcard: naming the drone makes ffmpeg wait to be sent
|
|
# to, which is backwards. The port is ours, the one we named in the
|
|
# handshake, and ffmpeg binds it.
|
|
text = video.sdp_text()
|
|
assert "c=IN IP4 0.0.0.0" in text
|
|
assert f"m=video {video.STREAM_PORT} RTP/AVP 96" in text
|
|
assert "a=rtpmap:96 H264/90000" in text
|
|
assert text.startswith("v=0\n")
|
|
assert "rtsp" not in text.lower()
|
|
|
|
|
|
def test_sdp_lines_are_in_order_and_complete():
|
|
keys = [line.split("=", 1)[0] for line in video.sdp_text().strip().splitlines()]
|
|
assert keys == ["v", "o", "s", "c", "t", "m", "a"]
|
|
|
|
|
|
def test_sdp_port_is_overridable():
|
|
assert "m=video 60000 RTP/AVP 96" in video.sdp_text(port=60000)
|
|
|
|
|
|
def test_write_sdp_creates_parents(tmp_path):
|
|
out = video.write_sdp(tmp_path / "nested" / "bebop.sdp")
|
|
assert out.read_text() == video.sdp_text()
|
|
|
|
|
|
@pytest.fixture
|
|
def sdp(tmp_path):
|
|
return tmp_path / "bebop.sdp"
|
|
|
|
|
|
def test_every_sink_whitelists_rtp_and_udp(sdp, tmp_path):
|
|
# ffmpeg refuses to open rtp/udp referenced from a file-based SDP unless
|
|
# they are explicitly whitelisted, and the failure reads like a bad file.
|
|
sinks = [
|
|
video.view(sdp),
|
|
video.record(sdp, tmp_path / "out.mp4"),
|
|
video.snapshot(sdp, tmp_path / "out.png"),
|
|
]
|
|
for sink in sinks:
|
|
argv = sink.argv
|
|
assert "-protocol_whitelist" in argv
|
|
assert argv[argv.index("-protocol_whitelist") + 1] == "file,rtp,udp"
|
|
|
|
|
|
def test_record_copies_the_stream_rather_than_re_encoding(sdp, tmp_path):
|
|
argv = video.record(sdp, tmp_path / "out.mp4").argv
|
|
assert argv[argv.index("-c") + 1] == "copy"
|
|
assert "-t" not in argv
|
|
assert argv[-1] == str(tmp_path / "out.mp4")
|
|
|
|
|
|
def test_record_with_a_duration_passes_t(sdp, tmp_path):
|
|
argv = video.record(sdp, tmp_path / "out.mp4", seconds=12.5).argv
|
|
assert argv[argv.index("-t") + 1] == "12.5"
|
|
|
|
|
|
def test_snapshot_skips_the_settle_period_and_asks_for_one_image(sdp, tmp_path):
|
|
# -ss discards the frames that reference an SPS/PPS we have not seen yet
|
|
# ("non-existing PPS 0") and lets auto-exposure settle. -update is what
|
|
# lets a single filename work at all; image2 otherwise demands %03d.
|
|
argv = video.snapshot(sdp, tmp_path / "f.png", settle_seconds=3).argv
|
|
assert argv[argv.index("-ss") + 1] == "3"
|
|
assert argv[argv.index("-frames:v") + 1] == "1"
|
|
assert argv[argv.index("-update") + 1] == "1"
|
|
# -ss has to come after -i here: we are discarding received frames, not
|
|
# seeking in a file.
|
|
assert argv.index("-i") < argv.index("-ss")
|
|
|
|
|
|
def test_snapshot_settle_default_is_not_zero(sdp, tmp_path):
|
|
argv = video.snapshot(sdp, tmp_path / "f.png").argv
|
|
assert float(argv[argv.index("-ss") + 1]) > 0
|
|
|
|
|
|
def test_view_uses_ffplay_and_low_latency_flags(sdp):
|
|
sink = video.view(sdp, title="hello")
|
|
assert sink.tool == "ffplay"
|
|
assert sink.argv[0] == "ffplay"
|
|
assert "-nobuffer" not in sink.argv # it is a value, not a flag
|
|
assert sink.argv[sink.argv.index("-fflags") + 1] == "nobuffer"
|
|
assert sink.argv[sink.argv.index("-window_title") + 1] == "hello"
|
|
|
|
|
|
def test_no_sink_opens_a_stream_url(sdp, tmp_path):
|
|
# Firmware 4.7.1 refuses port 554 entirely, so an rtsp:// URL anywhere
|
|
# here would be a regression to what pyparrot does. Match on the scheme
|
|
# rather than the bare word: pytest's own tmp_path is named after the test.
|
|
sinks = (video.view(sdp), video.record(sdp, tmp_path / "a.mp4"), video.snapshot(sdp, tmp_path / "a.png"))
|
|
for sink in sinks:
|
|
assert not any(part.startswith(("rtsp://", "rtmp://", "http://")) for part in sink.argv)
|
|
assert not any(":554" in part for part in sink.argv)
|
|
|
|
|
|
# --- downscale ---------------------------------------------------------------
|
|
|
|
|
|
def _png(width: int, height: int) -> bytes:
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (width, height), (40, 90, 140)).save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _jpeg(width: int, height: int) -> bytes:
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (width, height), (40, 90, 140)).save(buf, format="JPEG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_downscale_shrinks_and_keeps_aspect_ratio():
|
|
out = video.downscale(_png(856, 480), 640)
|
|
with Image.open(io.BytesIO(out)) as im:
|
|
assert im.width == 640
|
|
assert im.height == round(480 * 640 / 856)
|
|
assert im.format == "PNG"
|
|
|
|
|
|
def test_downscale_leaves_a_narrow_image_byte_for_byte():
|
|
data = _png(320, 180)
|
|
assert video.downscale(data, 640) is data
|
|
|
|
|
|
def test_downscale_at_exactly_max_width_is_a_passthrough():
|
|
data = _png(640, 360)
|
|
assert video.downscale(data, 640) is data
|
|
|
|
|
|
def test_downscale_preserves_jpeg_format():
|
|
out = video.downscale(_jpeg(856, 480), 200)
|
|
with Image.open(io.BytesIO(out)) as im:
|
|
assert im.format == "JPEG"
|
|
assert im.width == 200
|
|
|
|
|
|
def test_downscale_handles_rgba_into_jpeg():
|
|
buf = io.BytesIO()
|
|
Image.new("RGBA", (856, 480), (1, 2, 3, 255)).save(buf, format="PNG")
|
|
out = video.downscale(buf.getvalue(), 100)
|
|
with Image.open(io.BytesIO(out)) as im:
|
|
assert im.width == 100
|
|
|
|
|
|
def test_downscale_actually_saves_bytes():
|
|
big = _png(856, 480)
|
|
assert len(video.downscale(big, 160)) < len(big)
|
|
|
|
|
|
def test_downscale_rejects_a_nonsense_width():
|
|
with pytest.raises(ValueError):
|
|
video.downscale(_png(856, 480), 0)
|
|
|
|
|
|
# --- StreamSession -----------------------------------------------------------
|
|
|
|
|
|
class FakeProc:
|
|
def __init__(self, argv):
|
|
self.argv = argv
|
|
self.terminated = False
|
|
self.killed = False
|
|
self._returncode = None
|
|
|
|
def poll(self):
|
|
return self._returncode
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
self._returncode = 0
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
self._returncode = -9
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
|
|
@pytest.fixture
|
|
def harness(monkeypatch, tmp_path):
|
|
"""Record the order of (subprocess start, command send) events."""
|
|
events: list[tuple] = []
|
|
procs: list[FakeProc] = []
|
|
|
|
def fake_popen(argv, *a, **kw):
|
|
events.append(("popen", argv))
|
|
proc = FakeProc(argv)
|
|
procs.append(proc)
|
|
return proc
|
|
|
|
monkeypatch.setattr(video.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(video.shutil, "which", lambda tool: f"/usr/bin/{tool}")
|
|
|
|
async def send(name, args):
|
|
events.append(("send", name, args))
|
|
return {"ok": True}
|
|
|
|
return events, procs, send
|
|
|
|
|
|
async def test_the_receiver_binds_before_the_stream_is_enabled(harness, tmp_path):
|
|
# RTP is connectionless. Enable the stream first and the opening packets,
|
|
# which carry the SPS/PPS, hit a closed port and are gone.
|
|
events, _, send = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
async with video.StreamSession(sink, send):
|
|
pass
|
|
assert events[0][0] == "popen"
|
|
assert events[1] == ("send", video.VIDEO_ENABLE, {"enable": 1})
|
|
|
|
|
|
async def test_the_stream_is_disabled_and_the_sink_stopped_on_exit(harness, tmp_path):
|
|
events, procs, send = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
async with video.StreamSession(sink, send):
|
|
pass
|
|
assert ("send", video.VIDEO_ENABLE, {"enable": 0}) in events
|
|
assert procs[0].terminated
|
|
|
|
|
|
async def test_exposure_is_set_then_restored(harness, tmp_path):
|
|
events, _, send = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
state = {video.EXPOSURE_STATE_KEY: 0.25}
|
|
async with video.StreamSession(
|
|
sink, send, exposure=1.0, read_state=lambda keys: {k: state[k] for k in keys}
|
|
):
|
|
pass
|
|
sends = [e for e in events if e[0] == "send"]
|
|
assert sends[0] == ("send", video.EXPOSURE, {"value": 1.0})
|
|
assert sends[-1] == ("send", video.EXPOSURE, {"value": 0.25})
|
|
|
|
|
|
async def test_exposure_outside_the_aircraft_range_is_refused(harness, tmp_path):
|
|
_, _, send = harness
|
|
with pytest.raises(ValueError):
|
|
await video.set_exposure(send, 3.0)
|
|
|
|
|
|
async def test_a_refused_videoenable_stops_the_subprocess(harness, tmp_path):
|
|
_, procs, _ = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
|
|
async def refusing(name, args):
|
|
raise RuntimeError("the drone did not acknowledge VideoEnable")
|
|
|
|
with pytest.raises(RuntimeError):
|
|
async with video.StreamSession(sink, refusing):
|
|
pass
|
|
assert procs and procs[0].terminated
|
|
|
|
|
|
async def test_a_missing_ffmpeg_is_reported_before_anything_starts(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(video.shutil, "which", lambda tool: None)
|
|
started = []
|
|
monkeypatch.setattr(video.subprocess, "Popen", lambda *a, **kw: started.append(a))
|
|
|
|
async def send(name, args):
|
|
raise AssertionError("must not reach the drone")
|
|
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
with pytest.raises(video.VideoUnavailable):
|
|
async with video.StreamSession(sink, send):
|
|
pass
|
|
assert not started
|
|
|
|
|
|
async def test_a_sink_that_outlives_sigterm_is_killed(harness, monkeypatch, tmp_path):
|
|
_, procs, send = harness
|
|
sink = video.view(tmp_path / "s.sdp")
|
|
|
|
class Stubborn(FakeProc):
|
|
def terminate(self):
|
|
self.terminated = True # but stays running
|
|
|
|
def wait(self, timeout=None):
|
|
if timeout is not None:
|
|
raise subprocess.TimeoutExpired("ffplay", timeout)
|
|
return 0
|
|
|
|
def popen(argv, *a, **kw):
|
|
procs.append(Stubborn(argv))
|
|
return procs[-1]
|
|
|
|
monkeypatch.setattr(video.subprocess, "Popen", popen)
|
|
async with video.StreamSession(sink, send):
|
|
pass
|
|
assert procs[-1].killed
|
|
|
|
|
|
async def test_wait_returns_none_while_the_sink_is_still_running(harness, tmp_path):
|
|
_, _, send = harness
|
|
sink = video.view(tmp_path / "s.sdp")
|
|
|
|
session = video.StreamSession(sink, send)
|
|
async with session:
|
|
session.proc.wait = lambda timeout=None: (_ for _ in ()).throw(
|
|
subprocess.TimeoutExpired("ffplay", timeout or 0)
|
|
)
|
|
assert await session.wait(0.01) is None
|
|
|
|
|
|
def test_the_module_does_not_import_the_parallel_streams():
|
|
# media/ must stay loadable while arsdk/ and protocol/ are still being
|
|
# written; it talks to them through a callable the caller supplies.
|
|
source = Path(video.__file__).read_text()
|
|
assert "import mcbebop.arsdk" not in source
|
|
assert "from mcbebop.arsdk" not in source
|
|
assert "from mcbebop.protocol" not in source
|
|
|
|
|
|
async def test_a_failed_start_puts_the_exposure_back(harness, tmp_path):
|
|
# __aexit__ never runs for a failed __aenter__, so the restore has to
|
|
# happen on the way out of __aenter__ or the next snapshot inherits it.
|
|
events, procs, _ = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
|
|
async def send(name, args):
|
|
events.append(("send", name, args))
|
|
if name == video.VIDEO_ENABLE:
|
|
raise RuntimeError("the drone did not acknowledge VideoEnable")
|
|
|
|
with pytest.raises(RuntimeError):
|
|
async with video.StreamSession(
|
|
sink, send, exposure=1.0, read_state=lambda keys: {video.EXPOSURE_STATE_KEY: -0.5}
|
|
):
|
|
pass
|
|
sends = [e for e in events if e[0] == "send"]
|
|
assert sends[0] == ("send", video.EXPOSURE, {"value": 1.0})
|
|
assert sends[-1] == ("send", video.EXPOSURE, {"value": -0.5})
|
|
assert procs[0].terminated
|
|
|
|
|
|
async def test_a_rejected_exposure_does_not_trip_the_restore_path(harness, tmp_path):
|
|
# The exposure was never changed, so there is nothing to put back and no
|
|
# NameError on the way out either.
|
|
events, procs, send = harness
|
|
sink = video.snapshot(tmp_path / "s.sdp", tmp_path / "f.png")
|
|
with pytest.raises(ValueError):
|
|
async with video.StreamSession(sink, send, exposure=99.0):
|
|
pass
|
|
assert not [e for e in events if e[0] == "send"]
|
|
assert procs[0].terminated
|