arsdk: discovery, transport, session, and the ported simulator

The transport is threaded and the session puts an async face on it, so
pings and acks are answered whether or not anyone is awaiting a
coroutine. Telemetry is stored per key with a timestamp, because a drone
that has stopped reporting otherwise reads identically to one repeating
itself.

Encoding goes through protocol/codec lazily, so this lands without
waiting for that stream; the sim carries a small encoder of its own for
the events it sends, which also keeps it from agreeing with the client
about a shared mistake.

Ported from bebop-2's sim.py, retargeted at the vendored XML and with
the identity burst now sent per controller attach rather than once per
process.
This commit is contained in:
2026-10-02 00:22:06 -06:00
parent 5732befe82
commit 96ab07889d
7 changed files with 2142 additions and 0 deletions
+109
View File
@@ -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)
+399
View File
@@ -0,0 +1,399 @@
"""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)
+286
View File
@@ -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 == ""