Merge ARSDK client: discovery, connection, session, simulator
76 tests. Implements the protocol directly rather than through pyparrot, whose receive thread prints to stdout and would corrupt JSON-RPC. Two corrections to the observed notes, both adopted: the emergency buffer carries DATA_WITH_ACK with unlimited retries in libARController rather than the low-latency type pyparrot sends, and fire-and-forget is the wrong property for the command that cuts the motors; and the handshake reply must be read until its NUL terminator rather than from a single recv, because it has grown across firmware versions.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Framing, with no sockets involved.
|
||||
|
||||
These are the cases that cost real debugging time on the live aircraft: a
|
||||
datagram carrying several frames, a size field that would never advance the
|
||||
cursor, and the command id being two bytes rather than one.
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from mcbebop.arsdk.types import COMMAND_HEADER, FRAME_HEADER, BufferId, DataType, Frame
|
||||
|
||||
|
||||
def test_header_is_seven_bytes():
|
||||
# Everything about the size field depends on this, including the sim.
|
||||
assert FRAME_HEADER.size == 7
|
||||
assert COMMAND_HEADER.size == 4
|
||||
|
||||
|
||||
def test_encode_declares_size_including_header():
|
||||
frame = Frame(DataType.DATA, BufferId.C2D_NON_ACK, 3, b"abcd")
|
||||
raw = frame.encode()
|
||||
data_type, buffer_id, seq, size = FRAME_HEADER.unpack_from(raw)
|
||||
assert (data_type, buffer_id, seq) == (2, 10, 3)
|
||||
assert size == len(raw) == 11
|
||||
|
||||
|
||||
def test_round_trip():
|
||||
frame = Frame(DataType.DATA_WITH_ACK, BufferId.C2D_ACK, 255, bytes(range(16)))
|
||||
assert Frame.decode_all(frame.encode()) == [frame]
|
||||
|
||||
|
||||
def test_empty_payload_round_trips():
|
||||
frame = Frame(DataType.ACK, 139, 0, b"")
|
||||
assert Frame.decode_all(frame.encode()) == [frame]
|
||||
|
||||
|
||||
def test_several_frames_in_one_datagram():
|
||||
frames = [
|
||||
Frame(DataType.DATA, BufferId.D2C_NON_ACK, 1, b"\x01\x02"),
|
||||
Frame(DataType.DATA_WITH_ACK, BufferId.D2C_ACK, 2, b"xyz"),
|
||||
Frame(DataType.DATA, BufferId.PING, 3, struct.pack("<Q", 9)),
|
||||
]
|
||||
assert Frame.decode_all(b"".join(f.encode() for f in frames)) == frames
|
||||
|
||||
|
||||
def test_zero_size_does_not_loop_forever():
|
||||
# A size below the header length can never advance the cursor. The parse
|
||||
# has to stop rather than spin, which is why decode_all checks it.
|
||||
bogus = FRAME_HEADER.pack(2, 126, 0, 0) + b"junk"
|
||||
assert Frame.decode_all(bogus) == []
|
||||
|
||||
|
||||
def test_size_shorter_than_header_after_a_good_frame():
|
||||
good = Frame(DataType.DATA, BufferId.D2C_NON_ACK, 7, b"ok")
|
||||
data = good.encode() + FRAME_HEADER.pack(2, 126, 8, 3)
|
||||
assert Frame.decode_all(data) == [good]
|
||||
|
||||
|
||||
def test_truncated_frame_is_dropped_not_guessed():
|
||||
# A frame claiming more than the datagram holds is a short read, and
|
||||
# half a command decodes into a plausible-looking wrong one.
|
||||
whole = Frame(DataType.DATA, BufferId.D2C_NON_ACK, 1, b"0123456789").encode()
|
||||
assert Frame.decode_all(whole[:-4]) == []
|
||||
|
||||
|
||||
def test_trailing_bytes_below_header_length_are_ignored():
|
||||
good = Frame(DataType.DATA, BufferId.D2C_NON_ACK, 1, b"hi")
|
||||
assert Frame.decode_all(good.encode() + b"\x02\x7e") == [good]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("buffer_id", "ack_buffer"),
|
||||
[
|
||||
(BufferId.C2D_ACK, 139), # the one pyparrot hardcodes, so a good check
|
||||
(BufferId.C2D_NON_ACK, 138),
|
||||
(BufferId.C2D_HIGH_PRIO, 140),
|
||||
(BufferId.D2C_ACK, 255), # 127 + 128 lands on the last valid buffer id
|
||||
(BufferId.PING, 128),
|
||||
],
|
||||
)
|
||||
def test_ack_buffer_is_offset_by_128(buffer_id, ack_buffer):
|
||||
assert BufferId.ack_for(buffer_id) == ack_buffer
|
||||
|
||||
|
||||
def test_ack_buffer_wraps_rather_than_overflowing():
|
||||
# 128 upwards would exceed the one-byte field; the modulo is the point.
|
||||
assert BufferId.ack_for(200) == 72
|
||||
assert all(0 <= BufferId.ack_for(b) < 256 for b in range(256))
|
||||
|
||||
|
||||
def test_command_id_is_two_bytes():
|
||||
# ardrone3.MediaStreaming.VideoEnable is (1, 21, 0); the ids that matter
|
||||
# for this are the ones above 255, which a u8 command field would mangle.
|
||||
raw = COMMAND_HEADER.pack(1, 21, 0)
|
||||
assert COMMAND_HEADER.unpack(raw) == (1, 21, 0)
|
||||
assert COMMAND_HEADER.unpack(COMMAND_HEADER.pack(0, 5, 300)) == (0, 5, 300)
|
||||
|
||||
|
||||
def test_command_payload_sits_inside_a_frame():
|
||||
payload = COMMAND_HEADER.pack(0, 4, 0) # common.Common.AllStates
|
||||
frame = Frame(DataType.DATA_WITH_ACK, BufferId.C2D_ACK, 0, payload)
|
||||
(decoded,) = Frame.decode_all(frame.encode())
|
||||
assert COMMAND_HEADER.unpack(decoded.payload) == (0, 4, 0)
|
||||
|
||||
|
||||
def test_data_type_numbers_match_the_captures():
|
||||
assert (DataType.ACK, DataType.DATA, DataType.LOW_LATENCY, DataType.DATA_WITH_ACK) == (1, 2, 3, 4)
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Session against the simulator, which is the whole reason the sim exists.
|
||||
|
||||
There is no real drone in CI, and a transport is exactly the kind of code
|
||||
that passes unit tests and then fails on a wire. So these run a `FakeBebop`
|
||||
on localhost and talk to it over real sockets: real handshake, real UDP, real
|
||||
acknowledgements, real receive thread.
|
||||
|
||||
`protocol/codec.py` is being written in a parallel stream, so the encoder and
|
||||
decoder here are small local ones built from the same vendored XML. The last
|
||||
test in this file uses the real codec once it exists, and skips until then.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mcbebop.arsdk import discovery
|
||||
from mcbebop.arsdk.session import ALL_SETTINGS, ALL_STATES, DroneSession, Timeouts
|
||||
from mcbebop.arsdk.types import COMMAND_HEADER, BufferId, Event, HandshakeError, NotConnected
|
||||
from mcbebop.protocol.types import Buffer, CommandSpec, Expectation
|
||||
from mcbebop.sim import FakeBebop, _fallback_encode_args, load_specs
|
||||
|
||||
_SPECS = load_specs()
|
||||
_BY_IDS = {spec.ids: spec for spec in _SPECS.values()}
|
||||
_FORMATS = {
|
||||
"u8": "<B", "i8": "<b", "u16": "<H", "i16": "<h", "u32": "<I", "i32": "<i",
|
||||
"u64": "<Q", "i64": "<q", "float": "<f", "double": "<d", "enum": "<i",
|
||||
} # fmt: skip
|
||||
|
||||
VIDEO_ENABLE = _SPECS["ardrone3.MediaStreaming.VideoEnable"]
|
||||
PCMD = _SPECS["ardrone3.Piloting.PCMD"]
|
||||
EMERGENCY = _SPECS["ardrone3.Piloting.Emergency"]
|
||||
ATTITUDE = (1, 4, 6)
|
||||
|
||||
|
||||
def fake_encode(spec, args):
|
||||
"""The client-side encoder under test is not ours, so reuse the sim's."""
|
||||
return _fallback_encode_args(spec, args)
|
||||
|
||||
|
||||
def fake_decode(payload: bytes) -> Event:
|
||||
"""Decode a drone event the way the real codec is specified to.
|
||||
|
||||
Keys are `<Command>_<arg>`, matching the captures in bebop-2's notes.
|
||||
"""
|
||||
ids = COMMAND_HEADER.unpack_from(payload)
|
||||
spec = _BY_IDS[ids]
|
||||
offset = COMMAND_HEADER.size
|
||||
values = {}
|
||||
for arg in spec.args:
|
||||
if arg.type == "string":
|
||||
end = payload.index(b"\x00", offset)
|
||||
value = payload[offset:end].decode()
|
||||
offset = end + 1
|
||||
else:
|
||||
fmt = _FORMATS[arg.type]
|
||||
(value,) = struct.unpack_from(fmt, payload, offset)
|
||||
offset += struct.calcsize(fmt)
|
||||
if arg.is_enum and 0 <= value < len(arg.members):
|
||||
value = arg.members[value].name
|
||||
values[f"{spec.name}_{arg.name}"] = value
|
||||
return Event(ids=ids, name=spec.name, values=values, at=time.monotonic())
|
||||
|
||||
|
||||
def quick_timeouts() -> Timeouts:
|
||||
"""Short but not instant: the sim streams at 5 Hz."""
|
||||
return Timeouts(handshake=2.0, ack=0.5, ack_attempts=2, confirm=1.5, full_state=1.5)
|
||||
|
||||
|
||||
async def until(session: DroneSession, key: str, timeout: float = 3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
values = session.values()
|
||||
if key in values:
|
||||
return values[key]
|
||||
await asyncio.sleep(0.05)
|
||||
pytest.fail(f"{key} never arrived; saw {sorted(session.values())}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sim():
|
||||
with FakeBebop() as fake:
|
||||
yield fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(sim):
|
||||
drone = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0, # the OS picks, so tests can run side by side
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=fake_decode,
|
||||
)
|
||||
await drone.connect()
|
||||
yield drone
|
||||
await drone.disconnect()
|
||||
|
||||
|
||||
# -- handshake -----------------------------------------------------------
|
||||
async def test_handshake_yields_a_c2d_port(sim, session):
|
||||
assert session.connected
|
||||
assert session.handshake["status"] == 0
|
||||
assert session.handshake["c2d_port"] == sim.c2d_port
|
||||
|
||||
|
||||
async def test_handshake_describes_this_controller(sim, session):
|
||||
request = sim.handshakes[0]
|
||||
assert request["controller_name"] == "mcbebop"
|
||||
assert request["controller_type"] == "computer"
|
||||
# The video ports are named in the handshake, not in a URL: firmware
|
||||
# 4.7.1 serves no RTSP.
|
||||
assert request["arstream2_client_stream_port"] == 55004
|
||||
assert request["arstream2_client_control_port"] == 55005
|
||||
assert request["d2c_port"] == session.d2c_port != 0
|
||||
|
||||
|
||||
async def test_second_controller_is_refused(sim, session):
|
||||
other = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0,
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=fake_decode,
|
||||
)
|
||||
with pytest.raises(HandshakeError, match="one controller"):
|
||||
await other.connect()
|
||||
assert not other.connected
|
||||
|
||||
|
||||
async def test_unreachable_address_fails_fast_rather_than_hanging():
|
||||
drone = DroneSession("127.0.0.1", discovery_port=1, timeouts=Timeouts(handshake=1.0), d2c_port=0)
|
||||
started = time.monotonic()
|
||||
with pytest.raises(HandshakeError):
|
||||
await drone.connect()
|
||||
assert time.monotonic() - started < 3.0
|
||||
|
||||
|
||||
# -- telemetry -----------------------------------------------------------
|
||||
async def test_identity_and_attitude_populate_state(session):
|
||||
assert await until(session, "ProductVersionChanged_software") == "4.7.1"
|
||||
assert await until(session, "ProductVersionChanged_hardware") == "HW_05"
|
||||
assert await until(session, "BatteryStateChanged_percent") == 87
|
||||
assert await until(session, "FlyingStateChanged_state") == "landed"
|
||||
assert abs(await until(session, "AttitudeChanged_roll")) < 0.1
|
||||
|
||||
|
||||
async def test_gps_reports_the_no_position_sentinel(session):
|
||||
assert await until(session, "PositionChanged_latitude", timeout=4.0) == 500.0
|
||||
assert await until(session, "GPSFixStateChanged_fixed") == 0
|
||||
|
||||
|
||||
async def test_state_reports_staleness(session):
|
||||
await until(session, "BatteryStateChanged_percent")
|
||||
reading = session.state(["BatteryStateChanged"])["BatteryStateChanged_percent"]
|
||||
assert reading["value"] == 87
|
||||
first = reading["age"]
|
||||
await asyncio.sleep(0.3)
|
||||
# The same value, older. That difference is the whole reason for the
|
||||
# timestamp: a drone that has stopped talking reads identically without it.
|
||||
assert session.state(["BatteryStateChanged"])["BatteryStateChanged_percent"]["age"] > first
|
||||
|
||||
|
||||
async def test_state_filters_by_key_or_prefix(session):
|
||||
await until(session, "AttitudeChanged_roll")
|
||||
assert set(session.state(["AttitudeChanged_roll"])) == {"AttitudeChanged_roll"}
|
||||
assert set(session.state(["AttitudeChanged"])) >= {
|
||||
"AttitudeChanged_roll",
|
||||
"AttitudeChanged_pitch",
|
||||
"AttitudeChanged_yaw",
|
||||
}
|
||||
assert session.state(["NothingLikeThis"]) == {}
|
||||
|
||||
|
||||
async def test_subscribe_pushes_events_and_unsubscribe_stops_them(session):
|
||||
seen: list[str] = []
|
||||
stop = session.subscribe(lambda event: seen.append(event.name))
|
||||
await until(session, "AttitudeChanged_yaw")
|
||||
await asyncio.sleep(0.3)
|
||||
assert "AttitudeChanged" in seen
|
||||
stop()
|
||||
count = len(seen)
|
||||
await asyncio.sleep(0.4)
|
||||
assert len(seen) == count
|
||||
|
||||
|
||||
async def test_ping_is_answered(sim, session):
|
||||
await until(session, "AttitudeChanged_roll")
|
||||
await asyncio.sleep(0.3)
|
||||
# Both halves: we sent a pong, and the drone side received it.
|
||||
assert session.link_stats()["pings_answered"] > 0
|
||||
assert sim.pongs > 0
|
||||
|
||||
|
||||
async def test_link_stats_describe_a_live_link(session):
|
||||
await until(session, "BatteryStateChanged_percent")
|
||||
stats = session.link_stats()
|
||||
assert stats["connected"] and stats["frames_in"] > 0 and stats["frames_out"] > 0
|
||||
assert stats["telemetry_keys"] > 10
|
||||
assert stats["last_event_age"] < 2.0
|
||||
assert stats["undecodable_events"] == 0
|
||||
|
||||
|
||||
# -- sending -------------------------------------------------------------
|
||||
async def test_command_reaches_the_drone_with_the_right_ids(sim, session):
|
||||
result = await session.send(VIDEO_ENABLE, {"enable": 1}, confirm=False)
|
||||
assert result["acked"] is True
|
||||
assert result["buffer"] == BufferId.C2D_ACK
|
||||
sent = [r for r in sim.received if r.ids == (1, 21, 0)]
|
||||
assert sent, f"sim saw {sim.received_ids()}"
|
||||
assert sent[0].args == b"\x01"
|
||||
assert sent[0].data_type == 4 # DATA_WITH_ACK
|
||||
|
||||
|
||||
async def test_non_ack_commands_go_out_on_buffer_ten_unacknowledged(sim, session):
|
||||
assert PCMD.buffer == Buffer.NON_ACK
|
||||
args = {"flag": 1, "roll": 0, "pitch": 10, "yaw": 0, "gaz": 0, "timestampAndSeqNum": 0}
|
||||
result = await session.send(PCMD, args, confirm=False)
|
||||
assert result["buffer"] == BufferId.C2D_NON_ACK
|
||||
assert result["acked"] is None # nothing to wait for, and we did not
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and not any(r.ids == PCMD.ids for r in sim.received):
|
||||
await asyncio.sleep(0.02)
|
||||
assert [r.data_type for r in sim.received if r.ids == PCMD.ids] == [2]
|
||||
|
||||
|
||||
async def test_emergency_goes_out_acknowledged_on_buffer_twelve(sim, session):
|
||||
# libARController configures buffer 12 as DATA_WITH_ACK with unlimited
|
||||
# retries. pyparrot sends LOW_LATENCY there, which is fire and forget.
|
||||
assert EMERGENCY.buffer == Buffer.HIGH_PRIO
|
||||
result = await session.send(EMERGENCY, {}, confirm=False)
|
||||
assert result["buffer"] == BufferId.C2D_HIGH_PRIO
|
||||
assert result["acked"] is True
|
||||
assert [r.data_type for r in sim.received if r.ids == (1, 0, 4)] == [4]
|
||||
|
||||
|
||||
async def test_sequence_numbers_advance_per_buffer(sim, session):
|
||||
first = await session.send(VIDEO_ENABLE, {"enable": 1}, confirm=False)
|
||||
second = await session.send(VIDEO_ENABLE, {"enable": 0}, confirm=False)
|
||||
assert second["seq"] == first["seq"] + 1
|
||||
|
||||
|
||||
async def test_confirmation_comes_from_the_drone_report(session):
|
||||
spec = CommandSpec(
|
||||
project="ardrone3",
|
||||
klass="MediaStreaming",
|
||||
name="VideoEnable",
|
||||
ids=VIDEO_ENABLE.ids,
|
||||
args=VIDEO_ENABLE.args,
|
||||
expectations=(Expectation(ids=ATTITUDE),),
|
||||
)
|
||||
result = await session.send(spec, {"enable": 1})
|
||||
assert result["confirmed"]["ids"] == list(ATTITUDE)
|
||||
assert "AttitudeChanged_yaw" in result["confirmed"]["values"]
|
||||
|
||||
|
||||
async def test_a_command_with_no_expectation_says_so_rather_than_timing_out(session):
|
||||
result = await session.send(VIDEO_ENABLE, {"enable": 1}, confirm=True)
|
||||
assert result["acked"] is True
|
||||
assert result["confirmed"] is None
|
||||
assert "no confirming event" in result["note"]
|
||||
# It must not have sat out the confirm window waiting for an event that
|
||||
# was never coming.
|
||||
assert result["elapsed_ms"] < 1000
|
||||
|
||||
|
||||
async def test_an_expectation_that_does_not_match_is_not_reported_as_confirmed(session):
|
||||
# `this.enable` means the event should echo what we sent. Attitude never
|
||||
# will, so this must come back unconfirmed rather than take any event
|
||||
# with the right ids.
|
||||
spec = CommandSpec(
|
||||
project="ardrone3",
|
||||
klass="MediaStreaming",
|
||||
name="VideoEnable",
|
||||
ids=VIDEO_ENABLE.ids,
|
||||
args=VIDEO_ENABLE.args,
|
||||
expectations=(Expectation(ids=ATTITUDE, fields={"roll": "this.enable"}),),
|
||||
)
|
||||
result = await session.send(spec, {"enable": 1})
|
||||
assert result["acked"] is True
|
||||
assert result["confirmed"] is None
|
||||
assert "did not report the change" in result["note"]
|
||||
|
||||
|
||||
async def test_request_full_state_asks_for_both_and_collects_the_burst(sim, session):
|
||||
await until(session, "AttitudeChanged_roll")
|
||||
summary = await session.request_full_state()
|
||||
assert summary["sent"] == ["common.Common.AllStates", "common.Settings.AllSettings"]
|
||||
assert all(summary["acked"])
|
||||
assert ALL_STATES.ids in sim.received_ids()
|
||||
assert ALL_SETTINGS.ids in sim.received_ids()
|
||||
assert summary["telemetry_keys"] > 20
|
||||
|
||||
|
||||
async def test_a_dead_link_gives_up_instead_of_blocking(sim, session):
|
||||
await until(session, "BatteryStateChanged_percent")
|
||||
sim.stop() # the drone is gone; UDP will not notice, so the timeout must
|
||||
started = time.monotonic()
|
||||
result = await session.send(VIDEO_ENABLE, {"enable": 1}, confirm=False)
|
||||
elapsed = time.monotonic() - started
|
||||
assert result["acked"] is False
|
||||
# Two attempts at half a second, with room for scheduling.
|
||||
assert elapsed < 3.0, f"gave up only after {elapsed:.1f}s"
|
||||
|
||||
|
||||
async def test_sending_without_a_session_is_refused(sim):
|
||||
drone = DroneSession("127.0.0.1", discovery_port=sim.discovery_port, d2c_port=0, encoder=fake_encode)
|
||||
with pytest.raises(NotConnected):
|
||||
await drone.send(VIDEO_ENABLE, {"enable": 1})
|
||||
|
||||
|
||||
async def test_disconnect_stops_the_receive_thread(sim):
|
||||
drone = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0,
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=fake_decode,
|
||||
)
|
||||
await drone.connect()
|
||||
await until(drone, "AttitudeChanged_roll")
|
||||
await drone.disconnect()
|
||||
assert not drone.connected
|
||||
names = {thread.name for thread in threading.enumerate()}
|
||||
assert "arsdk-recv" not in names
|
||||
|
||||
|
||||
async def test_reconnect_after_the_drone_releases_the_slot(sim):
|
||||
first = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0,
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=fake_decode,
|
||||
)
|
||||
await first.connect()
|
||||
await first.disconnect()
|
||||
sim.release() # a real aircraft frees the slot when the link drops
|
||||
second = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0,
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=fake_decode,
|
||||
)
|
||||
await second.connect()
|
||||
try:
|
||||
assert await until(second, "BatteryStateChanged_percent") == 87
|
||||
finally:
|
||||
await second.disconnect()
|
||||
|
||||
|
||||
# -- the real codec, once it lands ---------------------------------------
|
||||
async def test_against_the_real_codec(sim):
|
||||
"""Same session, but through `protocol/codec.py` rather than local fakes.
|
||||
|
||||
Skipped until the protocol stream lands. When it starts failing, the two
|
||||
streams disagree about the wire, which is exactly what this is for.
|
||||
"""
|
||||
pytest.importorskip("mcbebop.protocol.codec", reason="protocol stream has not landed yet")
|
||||
drone = DroneSession(
|
||||
"127.0.0.1", discovery_port=sim.discovery_port, d2c_port=0, timeouts=quick_timeouts()
|
||||
)
|
||||
await drone.connect()
|
||||
try:
|
||||
assert await until(drone, "ProductVersionChanged_software") == "4.7.1"
|
||||
result = await drone.send(VIDEO_ENABLE, {"enable": 1}, confirm=False)
|
||||
assert result["acked"] is True
|
||||
assert [r.args for r in sim.received if r.ids == VIDEO_ENABLE.ids] == [b"\x01"]
|
||||
finally:
|
||||
await drone.disconnect()
|
||||
|
||||
|
||||
# -- discovery -----------------------------------------------------------
|
||||
def test_probe_sees_a_listening_drone(sim):
|
||||
assert discovery.probe("127.0.0.1", sim.discovery_port, timeout=1.0)
|
||||
|
||||
|
||||
def test_probe_gives_up_on_a_closed_port_quickly():
|
||||
started = time.monotonic()
|
||||
assert not discovery.probe("127.0.0.1", 1, timeout=0.5)
|
||||
assert time.monotonic() - started < 2.0
|
||||
|
||||
|
||||
def test_an_explicit_address_is_trusted_without_probing():
|
||||
# A caller who names an address wants the connection error, not a
|
||||
# discovery verdict, so find() must not quietly return None here.
|
||||
found = discovery.find("10.1.2.3")
|
||||
assert found is not None
|
||||
assert (found.ip, found.via) == ("10.1.2.3", "given")
|
||||
assert found.address == ("10.1.2.3", 44444)
|
||||
|
||||
|
||||
# -- an event we cannot name ---------------------------------------------
|
||||
async def test_an_undecodable_event_is_counted_not_fatal(sim):
|
||||
"""`decode_event` raises on an id triple the XML does not have.
|
||||
|
||||
The XML is demonstrably incomplete, so this happens on a real aircraft.
|
||||
One such event must not take the receive thread, and therefore the link,
|
||||
down with it.
|
||||
"""
|
||||
battery = _SPECS["common.CommonState.BatteryStateChanged"].ids
|
||||
|
||||
def decoder(payload):
|
||||
ids = COMMAND_HEADER.unpack_from(payload)
|
||||
if ids == battery:
|
||||
raise ValueError(f"no command with ids {ids}")
|
||||
return fake_decode(payload)
|
||||
|
||||
drone = DroneSession(
|
||||
"127.0.0.1",
|
||||
discovery_port=sim.discovery_port,
|
||||
d2c_port=0,
|
||||
timeouts=quick_timeouts(),
|
||||
encoder=fake_encode,
|
||||
decoder=decoder,
|
||||
)
|
||||
await drone.connect()
|
||||
try:
|
||||
assert await until(drone, "AttitudeChanged_roll") is not None
|
||||
stats = drone.link_stats()
|
||||
assert stats["undecodable_events"] > 0
|
||||
assert "BatteryStateChanged_percent" not in drone.values()
|
||||
# The rest of the stream kept arriving, and commands still work.
|
||||
result = await drone.send(VIDEO_ENABLE, {"enable": 1}, confirm=False)
|
||||
assert result["acked"] is True
|
||||
finally:
|
||||
await drone.disconnect()
|
||||
@@ -0,0 +1,286 @@
|
||||
"""The simulator, checked with raw sockets rather than through our own client.
|
||||
|
||||
If the sim were only ever exercised by `arsdk/`, the two could agree about a
|
||||
mistake and nothing would notice. So these tests do the handshake and the
|
||||
framing by hand, and assert on bytes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mcbebop.arsdk.types import COMMAND_HEADER, BufferId, DataType, Frame
|
||||
from mcbebop.protocol.types import Buffer
|
||||
from mcbebop.sim import FakeBebop, _fallback_encode_args, _wire_value, load_specs, serve
|
||||
|
||||
SPECS = load_specs()
|
||||
|
||||
|
||||
class Controller:
|
||||
"""A hand-rolled controller: TCP handshake, then UDP both ways."""
|
||||
|
||||
def __init__(self, sim: FakeBebop) -> None:
|
||||
self.udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.udp.bind(("127.0.0.1", 0))
|
||||
self.udp.settimeout(0.3)
|
||||
self.d2c_port = self.udp.getsockname()[1]
|
||||
request = {
|
||||
"d2c_port": self.d2c_port,
|
||||
"controller_type": "computer",
|
||||
"controller_name": "test",
|
||||
"arstream2_client_stream_port": 55004,
|
||||
"arstream2_client_control_port": 55005,
|
||||
}
|
||||
with socket.create_connection(("127.0.0.1", sim.discovery_port), timeout=2) as tcp:
|
||||
tcp.sendall(json.dumps(request).encode())
|
||||
raw = b""
|
||||
while b"\x00" not in raw:
|
||||
raw += tcp.recv(4096)
|
||||
self.reply = json.loads(raw.split(b"\x00")[0].decode())
|
||||
self.c2d = ("127.0.0.1", self.reply.get("c2d_port", 0))
|
||||
|
||||
def send(self, data_type: int, buffer_id: int, payload: bytes, seq: int = 0) -> None:
|
||||
self.udp.sendto(Frame(DataType(data_type), buffer_id, seq, payload).encode(), self.c2d)
|
||||
|
||||
def collect(self, seconds: float = 1.0) -> list[Frame]:
|
||||
frames: list[Frame] = []
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
data, _ = self.udp.recvfrom(65535)
|
||||
except TimeoutError:
|
||||
continue
|
||||
frames.extend(Frame.decode_all(data))
|
||||
return frames
|
||||
|
||||
def wait_for(self, predicate, seconds: float = 2.0) -> Frame:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
for frame in self.collect(0.3):
|
||||
if predicate(frame):
|
||||
return frame
|
||||
pytest.fail("the sim never sent a matching frame")
|
||||
|
||||
def close(self) -> None:
|
||||
self.udp.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sim():
|
||||
with FakeBebop() as fake:
|
||||
yield fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(sim):
|
||||
client = Controller(sim)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
def events(frames: list[Frame]) -> list[tuple[tuple[int, int, int], bytes]]:
|
||||
return [
|
||||
(COMMAND_HEADER.unpack_from(f.payload), f.payload[COMMAND_HEADER.size :])
|
||||
for f in frames
|
||||
if f.buffer_id in (BufferId.D2C_ACK, BufferId.D2C_NON_ACK) and len(f.payload) >= COMMAND_HEADER.size
|
||||
]
|
||||
|
||||
|
||||
# -- the spec table ------------------------------------------------------
|
||||
def test_every_command_is_indexed():
|
||||
assert len(SPECS) == 264
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "ids"),
|
||||
[
|
||||
# The table in bebop-2's protocol.md, which came off the aircraft.
|
||||
("common.Common.AllStates", (0, 4, 0)),
|
||||
("common.Settings.AllSettings", (0, 2, 0)),
|
||||
("ardrone3.Piloting.TakeOff", (1, 0, 1)),
|
||||
("ardrone3.Piloting.PCMD", (1, 0, 2)),
|
||||
("ardrone3.MediaStreaming.VideoEnable", (1, 21, 0)),
|
||||
],
|
||||
)
|
||||
def test_ids_match_the_observed_table(name, ids):
|
||||
assert SPECS[name].ids == ids
|
||||
|
||||
|
||||
def test_class_names_are_reused_across_projects():
|
||||
# Why the index is keyed by full name: these are different commands.
|
||||
assert "common.SettingsState.ProductNameChanged" in SPECS
|
||||
assert "ardrone3.SettingsState.MotorErrorStateChanged" in SPECS
|
||||
assert SPECS["common.SettingsState.ProductNameChanged"].ids[0] == 0
|
||||
assert SPECS["ardrone3.SettingsState.MotorErrorStateChanged"].ids[0] == 1
|
||||
|
||||
|
||||
def test_buffer_attribute_decides_the_frame_type():
|
||||
assert SPECS["ardrone3.Piloting.PCMD"].buffer == Buffer.NON_ACK
|
||||
assert SPECS["ardrone3.Piloting.Emergency"].buffer == Buffer.HIGH_PRIO
|
||||
assert SPECS["ardrone3.MediaStreaming.VideoEnable"].buffer == Buffer.ACK # attribute absent
|
||||
|
||||
|
||||
# -- encoding ------------------------------------------------------------
|
||||
def test_strings_are_nul_terminated():
|
||||
spec = SPECS["common.SettingsState.ProductVersionChanged"]
|
||||
assert _fallback_encode_args(spec, {"software": "4.7.1", "hardware": "HW_05"}) == b"4.7.1\x00HW_05\x00"
|
||||
|
||||
|
||||
def test_enums_travel_as_four_byte_positions():
|
||||
# pyparrot read these as one byte, which is wrong and shifts every
|
||||
# argument after them.
|
||||
spec = SPECS["ardrone3.PilotingState.FlyingStateChanged"]
|
||||
assert _fallback_encode_args(spec, {"state": "hovering"}) == struct.pack("<i", 2)
|
||||
assert _fallback_encode_args(spec, {"state": "landed"}) == struct.pack("<i", 0)
|
||||
|
||||
|
||||
def test_mixed_widths_pack_little_endian():
|
||||
spec = SPECS["ardrone3.SettingsState.MotorFlightsStatusChanged"]
|
||||
raw = _fallback_encode_args(
|
||||
spec, {"nbFlights": 42, "lastFlightDuration": 611, "totalFlightDuration": 26_340}
|
||||
)
|
||||
assert raw == struct.pack("<HHI", 42, 611, 26_340)
|
||||
|
||||
|
||||
def test_doubles_keep_the_no_position_sentinel_exactly():
|
||||
spec = SPECS["ardrone3.PilotingState.PositionChanged"]
|
||||
raw = _fallback_encode_args(spec, {"latitude": 500.0, "longitude": 500.0, "altitude": 500.0})
|
||||
assert struct.unpack("<ddd", raw) == (500.0, 500.0, 500.0)
|
||||
|
||||
|
||||
def test_an_unknown_enum_member_is_an_error_not_a_zero():
|
||||
spec = SPECS["ardrone3.PilotingState.FlyingStateChanged"]
|
||||
with pytest.raises(KeyError):
|
||||
_wire_value(spec.args[0], "hovvering")
|
||||
|
||||
|
||||
# -- handshake -----------------------------------------------------------
|
||||
def test_handshake_is_accepted_and_names_a_c2d_port(sim, controller):
|
||||
assert controller.reply["status"] == 0
|
||||
assert controller.reply["c2d_port"] == sim.c2d_port
|
||||
assert controller.reply["arstream2_server_stream_port"] == 5004
|
||||
assert sim.handshakes[0]["controller_name"] == "test"
|
||||
|
||||
|
||||
def test_a_second_controller_is_refused(sim, controller):
|
||||
second = Controller(sim)
|
||||
try:
|
||||
assert second.reply["status"] == 1
|
||||
finally:
|
||||
second.close()
|
||||
|
||||
|
||||
def test_the_slot_is_free_again_after_release(sim, controller):
|
||||
assert sim.occupied
|
||||
sim.release()
|
||||
third = Controller(sim)
|
||||
try:
|
||||
assert third.reply["status"] == 0
|
||||
finally:
|
||||
third.close()
|
||||
|
||||
|
||||
# -- what the drone sends ------------------------------------------------
|
||||
def test_identity_burst_arrives_on_the_acknowledged_buffer(controller):
|
||||
frames = controller.collect(1.0)
|
||||
version = SPECS["common.SettingsState.ProductVersionChanged"].ids
|
||||
matching = [payload for ids, payload in events(frames) if ids == version]
|
||||
assert matching == [b"4.7.1\x00HW_05\x00"]
|
||||
assert all(
|
||||
f.data_type == DataType.DATA_WITH_ACK
|
||||
for f in frames
|
||||
if f.buffer_id == BufferId.D2C_ACK # identity is sent acked, telemetry is not
|
||||
)
|
||||
|
||||
|
||||
def test_one_sensor_self_test_deliberately_fails(controller):
|
||||
sensors = SPECS["common.CommonState.SensorsStatesListChanged"]
|
||||
names = sensors.args[0].members
|
||||
results = {}
|
||||
for ids, payload in events(controller.collect(1.0)):
|
||||
if ids == sensors.ids:
|
||||
index, state = struct.unpack("<iB", payload)
|
||||
results[names[index].name] = state
|
||||
assert len(results) == 6, results
|
||||
# A simulator where everything passes never exercises the code that
|
||||
# notices something has not.
|
||||
assert results["magnetometer"] == 0
|
||||
assert sum(results.values()) == 5
|
||||
|
||||
|
||||
def test_telemetry_streams_at_roughly_five_hertz(controller):
|
||||
attitude = SPECS["ardrone3.PilotingState.AttitudeChanged"].ids
|
||||
count = sum(1 for ids, _ in events(controller.collect(1.2)) if ids == attitude)
|
||||
assert 3 <= count <= 9, f"{count} attitude events in 1.2s"
|
||||
|
||||
|
||||
def test_the_drone_pings_us(controller):
|
||||
ping = controller.wait_for(lambda f: f.buffer_id == BufferId.PING)
|
||||
assert len(ping.payload) == 8 # the drone's own clock, to be echoed back
|
||||
assert ping.data_type == DataType.DATA
|
||||
|
||||
|
||||
# -- what the drone receives ---------------------------------------------
|
||||
def test_an_acknowledged_command_is_acknowledged(sim, controller):
|
||||
payload = COMMAND_HEADER.pack(1, 21, 0) + b"\x01"
|
||||
controller.send(DataType.DATA_WITH_ACK, BufferId.C2D_ACK, payload, seq=7)
|
||||
ack = controller.wait_for(lambda f: f.data_type == DataType.ACK)
|
||||
assert ack.buffer_id == BufferId.ack_for(BufferId.C2D_ACK) == 139
|
||||
assert ack.payload == b"\x07" # the sequence acked, not the ack's own
|
||||
assert sim.received_ids().count((1, 21, 0)) == 1
|
||||
assert [r.args for r in sim.received if r.ids == (1, 21, 0)] == [b"\x01"]
|
||||
|
||||
|
||||
def test_a_non_ack_command_is_recorded_but_not_acknowledged(sim, controller):
|
||||
controller.collect(0.4) # drain the identity burst and its own frames
|
||||
controller.send(DataType.DATA, BufferId.C2D_NON_ACK, COMMAND_HEADER.pack(1, 0, 2) + bytes(9))
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and (1, 0, 2) not in sim.received_ids():
|
||||
time.sleep(0.02)
|
||||
assert (1, 0, 2) in sim.received_ids()
|
||||
assert not [f for f in controller.collect(0.4) if f.data_type == DataType.ACK]
|
||||
|
||||
|
||||
def test_a_pong_is_counted_not_mistaken_for_a_command(sim, controller):
|
||||
ping = controller.wait_for(lambda f: f.buffer_id == BufferId.PING)
|
||||
before = len(sim.received)
|
||||
controller.send(DataType.DATA, BufferId.PONG, ping.payload)
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and sim.pongs == 0:
|
||||
time.sleep(0.02)
|
||||
assert sim.pongs >= 1
|
||||
# An 8-byte pong body parses as a command header and would otherwise be
|
||||
# filed as a command the controller never sent.
|
||||
assert len(sim.received) == before
|
||||
|
||||
|
||||
def test_all_states_triggers_a_fresh_burst(sim, controller):
|
||||
controller.collect(0.6)
|
||||
version = SPECS["common.SettingsState.ProductVersionChanged"].ids
|
||||
controller.send(DataType.DATA_WITH_ACK, BufferId.C2D_ACK, COMMAND_HEADER.pack(0, 4, 0), seq=1)
|
||||
frames = controller.collect(1.0)
|
||||
assert any(ids == version for ids, _ in events(frames))
|
||||
assert (0, 4, 0) in sim.received_ids()
|
||||
|
||||
|
||||
def test_a_truncated_datagram_does_not_stop_the_sim(sim, controller):
|
||||
# A size field larger than the datagram used to be an infinite loop.
|
||||
controller.udp.sendto(struct.pack("<BBBI", 4, 11, 1, 400) + b"short", controller.c2d)
|
||||
controller.send(DataType.DATA_WITH_ACK, BufferId.C2D_ACK, COMMAND_HEADER.pack(1, 21, 0) + b"\x00", 2)
|
||||
assert controller.wait_for(lambda f: f.data_type == DataType.ACK) is not None
|
||||
|
||||
|
||||
# -- house rules ---------------------------------------------------------
|
||||
def test_ports_are_ephemeral_by_default(sim):
|
||||
assert sim.discovery_port != 0 and sim.c2d_port != 0
|
||||
assert sim.discovery_port != sim.c2d_port
|
||||
|
||||
|
||||
def test_serve_writes_nothing_to_stdout(capsys):
|
||||
# stdout is the MCP server's JSON-RPC transport and this module is
|
||||
# importable from it, so a stray print would corrupt the protocol.
|
||||
serve(0.3, discovery_port=0)
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user