"""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 sim_factory(): """For tests that need the refusing simulator rather than the default. The default models the aircraft: a second controller is accepted and the first is starved. Refusal is the opt-in mode. """ return FakeBebop @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("= 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("