Merge protocol layer: XML index, codec, safety tiers, expectations

90 tests. Verified against real data from the aircraft: VideoEnable encodes
to the bytes we actually sent, enums encode as 4-byte indices (pyparrot read
one byte and misaligned everything after), and events decode to the same
<Command>_<arg> keys our captures use.

Two counts in the brief were wrong and the stream corrected them with tests:
31 commands state neither result nor triggered (not 33), and 39 give no
answer on Bebop 2 support (not 32).
This commit is contained in:
2026-10-02 00:09:39 -06:00
8 changed files with 1451 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
"""Wire-format tests. The byte counts here are the whole point.
Two of them exist because pyparrot gets them wrong and pyparrot is the obvious
thing to check against: the command id is 16 bits, and an enum is 32.
"""
import struct
import pytest
from mcbebop.protocol import codec, xml_index
from mcbebop.protocol.types import ArgSpec, CommandSpec, EnumSpec
def test_header_is_four_bytes_with_a_16_bit_command_id():
assert codec.HEADER.size == 4
assert codec.HEADER.pack(1, 0, 2) == b"\x01\x00\x02\x00"
# An id above 255 must survive, even though no current command has one.
assert codec.HEADER.unpack(codec.HEADER.pack(1, 4, 300)) == (1, 4, 300)
def test_encode_command_is_header_plus_args():
spec = xml_index.get("ardrone3.MediaStreaming.VideoEnable")
assert codec.encode_command(spec, {"enable": 1}) == b"\x01\x15\x00\x00\x01"
def test_no_arg_command_encodes_to_nothing():
assert codec.encode_args(xml_index.get("ardrone3.Piloting.TakeOff")) == b""
assert codec.encode_args(xml_index.get("ardrone3.Piloting.FlatTrim"), {}) == b""
def test_enum_is_four_bytes_little_endian():
spec = xml_index.get("ardrone3.Network.WifiScan")
assert codec.encode_args(spec, {"band": "5ghz"}) == b"\x01\x00\x00\x00"
assert codec.encode_args(spec, {"band": "2_4ghz"}) == b"\x00\x00\x00\x00"
# An int position is accepted too, for replaying captured traffic.
assert codec.encode_args(spec, {"band": 2}) == b"\x02\x00\x00\x00"
def test_enum_decodes_back_to_the_member_name():
spec = xml_index.get("ardrone3.Network.WifiScan")
assert codec.decode_args(spec, b"\x01\x00\x00\x00") == {"WifiScan_band": "5ghz"}
def test_unknown_enum_position_decodes_to_the_number():
# A firmware member this XML snapshot predates must not raise.
spec = xml_index.get("ardrone3.Network.WifiScan")
assert codec.decode_args(spec, b"\x63\x00\x00\x00") == {"WifiScan_band": 99}
def test_string_is_nul_terminated():
spec = xml_index.get("common.Settings.ProductName")
assert codec.encode_args(spec, {"name": "Bebop"}) == b"Bebop\x00"
assert codec.decode_args(spec, b"Bebop\x00") == {"ProductName_name": "Bebop"}
def test_string_arguments_can_be_followed_by_more_arguments():
# A variable-length field mid-payload is where a fixed-width read goes
# wrong: everything after the string shifts.
spec = xml_index.get("ardrone3.NetworkState.WifiScanListChanged")
assert [a.type for a in spec.args] == ["string", "i16", "enum", "u8"]
payload = b"MyNet\x00" + struct.pack("<h", -70) + struct.pack("<i", 1) + b"\x0b"
assert codec.decode_args(spec, payload) == {
"WifiScanListChanged_ssid": "MyNet",
"WifiScanListChanged_rssi": -70,
"WifiScanListChanged_band": "5ghz",
"WifiScanListChanged_channel": 11,
}
def test_decoded_keys_match_the_existing_captures():
spec = xml_index.get("common.CommonState.BatteryStateChanged")
decoded = codec.decode_args(spec, b"\x4b")
assert decoded == {"BatteryStateChanged_percent": 75}
def test_decode_event_reads_the_header():
spec = xml_index.get("common.CommonState.BatteryStateChanged")
payload = codec.HEADER.pack(*spec.ids) + b"\x4b"
ids, values = codec.decode_event(payload)
assert ids == spec.ids
assert values == {"BatteryStateChanged_percent": 75}
def test_decode_event_rejects_a_truncated_header():
with pytest.raises(ValueError, match="too short"):
codec.decode_event(b"\x00\x04\x00")
def test_decode_event_names_an_unknown_command_rather_than_dropping_it():
with pytest.raises(ValueError, match=r"no command with ids \(7, 7, 7\)"):
codec.decode_event(codec.HEADER.pack(7, 7, 7))
def test_decode_rejects_a_short_payload():
spec = xml_index.get("ardrone3.Piloting.PCMD")
with pytest.raises(ValueError, match="bytes but only"):
codec.decode_args(spec, b"\x01\x00")
def test_decode_rejects_an_unterminated_string():
spec = xml_index.get("common.Settings.ProductName")
with pytest.raises(ValueError, match="unterminated string"):
codec.decode_args(spec, b"Bebop")
def test_pcmd_round_trip():
# The stick packet: the one command sent dozens of times a second.
spec = xml_index.get("ardrone3.Piloting.PCMD")
args = {"flag": 1, "roll": -20, "pitch": 30, "yaw": 0, "gaz": 10, "timestampAndSeqNum": 12345}
encoded = codec.encode_args(spec, args)
assert len(encoded) == 1 + 1 + 1 + 1 + 1 + 4
decoded = codec.decode_args(spec, encoded)
assert decoded["PCMD_roll"] == -20
assert decoded["PCMD_timestampAndSeqNum"] == 12345
def _plausible(arg: ArgSpec):
"""A value the spec allows, chosen so a round trip is exact."""
if arg.type == "string":
return "test"
if arg.is_enum:
return arg.members[0].name if arg.members else 0
if arg.is_bitfield:
return [arg.members[0].name] if arg.members else 0
if arg.type in ("float", "double"):
return 1.5 # exact in both widths, so no tolerance needed
if arg.type.startswith("i"):
return -3
return 3
def test_round_trip_every_command_that_has_arguments():
checked = 0
for spec in xml_index.all_commands():
if not spec.args:
continue
sent = {arg.name: _plausible(arg) for arg in spec.args}
decoded = codec.decode_args(spec, codec.encode_args(spec, sent))
assert len(decoded) == len(spec.args), spec.full_name
for arg in spec.args:
got = decoded[f"{spec.name}_{arg.name}"]
expected = sent[arg.name]
if arg.is_enum and not arg.members:
expected = 0
assert got == expected, f"{spec.full_name}.{arg.name} ({arg.type}) gave {got!r}"
checked += 1
assert checked == 234 # every command that declares at least one argument
def test_encoded_length_matches_the_declared_types():
sizes = {"u8": 1, "i8": 1, "u16": 2, "i16": 2, "u32": 4, "i32": 4, "u64": 8, "i64": 8}
sizes |= {"float": 4, "double": 8}
for spec in xml_index.all_commands():
if any(a.type == "string" for a in spec.args):
continue
expected = sum(4 if a.is_enum else sizes[a.type] for a in spec.args)
sent = {arg.name: _plausible(arg) for arg in spec.args}
assert len(codec.encode_args(spec, sent)) == expected, spec.full_name
def test_missing_argument_is_named():
spec = xml_index.get("ardrone3.Piloting.PCMD")
with pytest.raises(ValueError, match="requires argument 'flag'"):
codec.encode_args(spec, {})
def test_unknown_argument_is_named_and_the_real_ones_listed():
spec = xml_index.get("ardrone3.MediaStreaming.VideoEnable")
with pytest.raises(ValueError, match="has no argument nope; expected: enable"):
codec.encode_args(spec, {"enable": 1, "nope": 2})
def test_out_of_range_integer_is_named():
spec = xml_index.get("ardrone3.MediaStreaming.VideoEnable")
with pytest.raises(ValueError, match="'enable' \\(u8\\) cannot hold 999"):
codec.encode_args(spec, {"enable": 999})
def test_wrong_type_is_named():
spec = xml_index.get("ardrone3.MediaStreaming.VideoEnable")
with pytest.raises(ValueError, match="'enable' is a u8, got str"):
codec.encode_args(spec, {"enable": "yes"})
with pytest.raises(ValueError, match="pass 1 or 0 rather than a bool"):
codec.encode_args(spec, {"enable": True})
def test_unknown_enum_member_lists_the_allowed_ones():
spec = xml_index.get("ardrone3.Network.WifiScan")
with pytest.raises(ValueError, match="no member named 'wifi6'; allowed: 2_4ghz, 5ghz, all"):
codec.encode_args(spec, {"band": "wifi6"})
with pytest.raises(ValueError, match="no member with value 9"):
codec.encode_args(spec, {"band": 9})
def test_embedded_nul_in_a_string_is_refused():
spec = xml_index.get("common.Settings.ProductName")
with pytest.raises(ValueError, match="contains a NUL byte"):
codec.encode_args(spec, {"name": "Beb\x00op"})
def _bitfield_spec(width: str) -> CommandSpec:
"""Parrot's other projects use bitfields; these two files do not."""
arg = ArgSpec(
name="flags",
type=f"bitfield:{width}:Flags",
members=(EnumSpec("first", 0), EnumSpec("second", 1), EnumSpec("third", 2)),
)
return CommandSpec(project="x", klass="Y", name="Z", ids=(9, 9, 9), args=(arg,))
def test_bitfield_takes_member_names_or_a_mask():
spec = _bitfield_spec("u8")
assert codec.encode_args(spec, {"flags": ["first", "third"]}) == b"\x05"
assert codec.encode_args(spec, {"flags": "second"}) == b"\x02"
assert codec.encode_args(spec, {"flags": 0xFF}) == b"\xff"
assert codec.decode_args(spec, b"\x05") == {"Z_flags": 5}
def test_bitfield_width_sets_the_wire_size():
assert len(codec.encode_args(_bitfield_spec("u16"), {"flags": 1})) == 2
assert len(codec.encode_args(_bitfield_spec("u32"), {"flags": 1})) == 4
with pytest.raises(ValueError, match="unknown bitfield width 'u7'"):
codec.encode_args(_bitfield_spec("u7"), {"flags": 1})
def test_bitfield_rejects_an_unknown_member():
with pytest.raises(ValueError, match="no member named 'fourth'"):
codec.encode_args(_bitfield_spec("u8"), {"flags": ["fourth"]})
def test_unsupported_type_is_reported():
blob = ArgSpec(name="a", type="blob")
spec = CommandSpec(project="x", klass="Y", name="Z", ids=(9, 9, 8), args=(blob,))
with pytest.raises(ValueError, match="unsupported type 'blob'"):
codec.encode_args(spec, {"a": 1})
def test_every_type_in_the_xml_is_encodable():
declared = {a.type for c in xml_index.all_commands() for a in c.args}
for type_name in declared:
if type_name in ("string", "enum"):
continue
assert struct.calcsize(codec._FORMATS[type_name]) > 0
+113
View File
@@ -0,0 +1,113 @@
"""The expectations grammar is undocumented, so the examples are the spec."""
import xml.etree.ElementTree as ET
import pytest
from mcbebop.protocol import xml_index
from mcbebop.protocol.expectations import expectations_for, parse_expectation_text
from mcbebop.protocol.types import Direction
def test_every_block_is_parsed_and_only_sendable_commands_carry_one():
with_expectations = [c for c in xml_index.all_commands() if c.expectations]
assert len(with_expectations) == 82
assert all(c.direction is Direction.TO_DRONE for c in with_expectations)
def test_delayed_blocks():
delayed = [c.full_name for c in xml_index.all_commands() if any(e.delayed for e in c.expectations)]
assert sorted(delayed) == ["ardrone3.Network.WifiScan", "ardrone3.Network.WifiAuthChannel"][::-1]
def test_bare_reference():
(expectation,) = parse_expectation_text("#0-3-0")
assert expectation.ids == (0, 3, 0)
assert expectation.fields == {}
assert expectation.alternatives == ()
assert expectation.delayed is False
def test_this_dot_argname_means_the_drone_echoes_what_was_sent():
(expectation,) = xml_index.get("common.Settings.ProductName").expectations
assert expectation.ids == (0, 3, 2)
assert expectation.fields == {"name": "this.name"}
def test_whitespace_separated_references_are_all_expected():
expectations = xml_index.get("common.Settings.AutoCountry").expectations
assert len(expectations) == 2
assert expectations[0].ids == (0, 3, 7)
assert expectations[0].fields == {"automatic": "this.automatic"}
assert expectations[1].ids == (0, 3, 6)
assert all(e.alternatives == () for e in expectations)
def test_pipe_prefixed_references_are_alternatives():
(expectation,) = xml_index.get("ardrone3.Piloting.NavigateHome").expectations
assert expectation.ids == (1, 4, 3)
assert expectation.fields == {"state": "inProgress", "reason": "userRequest"}
assert len(expectation.alternatives) == 2
assert [a.fields["state"] for a in expectation.alternatives] == ["pending", "available"]
assert all(a.ids == (1, 4, 3) for a in expectation.alternatives)
def test_alternatives_can_name_different_commands():
# StopPilotedPOI is confirmed by whichever POI event version is in play.
(expectation,) = xml_index.get("ardrone3.Piloting.StopPilotedPOI").expectations
assert expectation.ids == (1, 4, 14)
assert [a.ids for a in expectation.alternatives] == [(1, 4, 22)]
def test_literal_enum_member_values():
(expectation,) = xml_index.get("ardrone3.Piloting.StartPilotedPOI").expectations
assert expectation.fields["status"] == "RUNNING"
assert expectation.fields["latitude"] == "this.latitude"
assert expectation.alternatives[0].fields["status"] == "PENDING"
def test_takeoff_expects_two_states_not_one_of_two():
expectations = xml_index.get("ardrone3.Piloting.TakeOff").expectations
assert [e.fields["state"] for e in expectations] == ["motor_ramping", "takingoff"]
def test_delayed_flag_reaches_the_alternatives_too():
parsed = parse_expectation_text("#1-14-0 |#1-14-1", delayed=True)
assert parsed[0].delayed is True
assert parsed[0].alternatives[0].delayed is True
def test_spaced_pipe_still_parses():
(expectation,) = parse_expectation_text("#0-1-2 | #0-1-3")
assert [a.ids for a in expectation.alternatives] == [(0, 1, 3)]
def test_empty_block_is_empty_not_an_error():
assert parse_expectation_text("\n\t\t\t") == ()
def test_leading_alternative_is_rejected():
with pytest.raises(ValueError, match="starts with an alternative"):
parse_expectation_text("|#0-1-2")
def test_text_without_a_reference_is_rejected():
with pytest.raises(ValueError, match="no #p-c-m reference"):
parse_expectation_text("ProductNameChanged")
def test_malformed_field_is_rejected():
with pytest.raises(ValueError, match="is not 'name: value'"):
parse_expectation_text("#0-3-2(name)")
def test_unknown_expectations_child_is_rejected():
cmd = ET.fromstring(
"<cmd name='X' id='0'><expectations><eventually>#0-0-0</eventually></expectations></cmd>"
)
with pytest.raises(ValueError, match="unknown expectations child"):
expectations_for(cmd)
def test_commands_without_a_block_have_an_empty_tuple():
assert xml_index.get("ardrone3.Piloting.PCMD").expectations == ()
+222
View File
@@ -0,0 +1,222 @@
"""The index is derived, not transcribed, so these pin the derivation.
Every count here was read off the two vendored XML files. A bad parse that
still produces plausible-looking objects is the failure mode worth catching,
so the numbers are asserted rather than the shapes.
"""
import collections
import shutil
import pytest
from mcbebop.protocol import xml_index
from mcbebop.protocol.types import Buffer, Direction, Tier
TOTAL_IN_XML = 264
TOTAL_WITH_FLAT_TRIM = 265
def test_command_count():
assert len(xml_index.all_commands()) == TOTAL_WITH_FLAT_TRIM
from_xml = [c for c in xml_index.all_commands() if c.name != "FlatTrim"]
assert len(from_xml) == TOTAL_IN_XML
def test_direction_split():
counts = collections.Counter(c.direction for c in xml_index.all_commands() if c.name != "FlatTrim")
assert counts[Direction.TO_DRONE] == 101
assert counts[Direction.FROM_DRONE] == 163
assert sum(counts.values()) == TOTAL_IN_XML
def test_direction_fallback_count():
"""31 commands state neither result nor triggered and fall back to the suffix."""
from xml.etree import ElementTree
directory = xml_index.xml_dir()
neither = 0
for name in xml_index.XML_FILES:
project = ElementTree.parse(directory / name).getroot()
for klass in project.iter("class"):
for cmd in klass.findall("cmd"):
comment = cmd.find("comment")
attrs = comment.attrib if comment is not None else {}
assert not ("result" in attrs and "triggered" in attrs), cmd.get("name")
if "result" not in attrs and "triggered" not in attrs:
neither += 1
assert neither == 31
def test_mixed_controller_class_is_not_decided_by_suffix():
# common.Controller holds one of each, which is why the suffix rule alone
# would be wrong there. Both state result/triggered, so it never applies.
assert xml_index.get("common.Controller.isPiloting").direction is Direction.TO_DRONE
assert xml_index.get("common.Controller.PeerStateChanged").direction is Direction.FROM_DRONE
def test_buffers():
counts = collections.Counter(c.buffer for c in xml_index.all_commands() if c.name != "FlatTrim")
assert counts[Buffer.ACK] == 248
assert counts[Buffer.NON_ACK] == 15
assert counts[Buffer.HIGH_PRIO] == 1
high_prio = [c.full_name for c in xml_index.all_commands() if c.buffer is Buffer.HIGH_PRIO]
assert high_prio == ["ardrone3.Piloting.Emergency"]
def test_deprecated_count():
assert sum(1 for c in xml_index.all_commands() if c.deprecated) == 39
def test_expectation_block_count():
blocks = sum(1 for c in xml_index.all_commands() if c.expectations)
assert blocks == 82
assert all(c.direction is Direction.TO_DRONE for c in xml_index.all_commands() if c.expectations)
def test_ids_are_unique_and_lookups_agree():
for command in xml_index.all_commands():
assert xml_index.by_ids(command.ids) is command
assert xml_index.get(command.full_name) is command
assert xml_index.get("ardrone3.Piloting.Nope") is None
assert xml_index.by_ids((9, 9, 9)) is None
def test_known_ids_match_the_live_aircraft():
# Read off real traffic, see docs/notes/protocol.md.
assert xml_index.get("common.Common.AllStates").ids == (0, 4, 0)
assert xml_index.get("common.Settings.AllSettings").ids == (0, 2, 0)
assert xml_index.get("ardrone3.Piloting.TakeOff").ids == (1, 0, 1)
assert xml_index.get("ardrone3.Piloting.PCMD").ids == (1, 0, 2)
def test_command_ids_have_gaps_and_document_order_is_not_id_order():
"""Never infer an id from a command's position in the file."""
piloting = [c for c in xml_index.all_commands() if f"{c.project}.{c.klass}" == "ardrone3.Piloting"]
in_document_order = [c.ids[2] for c in piloting]
assert in_document_order != sorted(in_document_order)
# ardrone3.PilotingState runs 1..22 with no id 0, so counting is not enough.
state = [
c.ids[2] for c in xml_index.all_commands() if f"{c.project}.{c.klass}" == "ardrone3.PilotingState"
]
assert len(state) < max(state) + 1
def test_flat_trim_is_injected():
flat_trim = xml_index.get("ardrone3.Piloting.FlatTrim")
assert flat_trim is not None
assert flat_trim.ids == (1, 0, 0)
assert flat_trim.args == ()
assert flat_trim.buffer is Buffer.ACK
assert flat_trim.direction is Direction.TO_DRONE
assert flat_trim.tier is Tier.MOTION
assert flat_trim.support is None
assert "missing from Parrot's XML" in flat_trim.doc
def test_commands_without_a_comment_keep_their_bare_text_as_doc():
bare = [
"common.CalibrationState.PitotCalibrationStateChanged",
"common.ARLibsVersionsState.ControllerLibARCommandsVersion",
"common.ARLibsVersionsState.SkyControllerLibARCommandsVersion",
"common.ARLibsVersionsState.DeviceLibARCommandsVersion",
"ardrone3.SettingsState.CPUID",
"ardrone3.MediaStreamingState.VideoStreamModeChanged",
]
for name in bare:
command = xml_index.get(name)
assert command.title == ""
assert command.doc, name
assert xml_index.get("ardrone3.SettingsState.CPUID").doc == "Product main cpu id"
def test_enum_values_are_positions_and_names_stay_verbatim():
band = xml_index.get("ardrone3.Network.WifiScan").args[0]
assert band.is_enum
assert [(m.name, m.value) for m in band.members] == [("2_4ghz", 0), ("5ghz", 1), ("all", 2)]
# Member names that are not valid identifiers must survive as strings.
framerate = xml_index.get("ardrone3.PictureSettings.VideoFramerate").args[0]
assert [m.name for m in framerate.members][:3] == ["24_FPS", "25_FPS", "30_FPS"]
def test_only_name_and_type_on_args():
# Nothing in the parse may depend on an arg attribute Parrot does not set.
for command in xml_index.all_commands():
for arg in command.args:
assert arg.name and arg.type
def test_search_ranks_the_command_above_the_prose():
results = xml_index.search("takeoff")
assert results
assert results[0].name == "TakeOff"
assert any(c.full_name == "ardrone3.PilotingState.FlyingStateChanged" for c in xml_index.search("flying"))
assert xml_index.search(" ") == []
def test_myclass_spelling_also_loads(tmp_path, monkeypatch):
"""pyparrot renamed <class> to <myclass>; a copy from it must still parse."""
source = xml_index.xml_dir()
for name in xml_index.XML_FILES:
text = (source / name).read_text()
text = text.replace("<class ", "<myclass ").replace("</class>", "</myclass>")
(tmp_path / name).write_text(text)
monkeypatch.setenv(xml_index.XML_DIR_ENV, str(tmp_path))
xml_index.index.cache_clear()
try:
assert len(xml_index.all_commands()) == TOTAL_WITH_FLAT_TRIM
finally:
monkeypatch.delenv(xml_index.XML_DIR_ENV)
xml_index.index.cache_clear()
def test_missing_xml_dir_says_what_to_set(tmp_path, monkeypatch):
real = xml_index.xml_dir()
shutil.copy(real / "common.xml", tmp_path / "common.xml") # one of the two, not both
monkeypatch.setenv(xml_index.XML_DIR_ENV, str(tmp_path))
xml_index.index.cache_clear()
try:
with pytest.raises(FileNotFoundError, match=xml_index.XML_DIR_ENV):
xml_index.all_commands()
finally:
monkeypatch.delenv(xml_index.XML_DIR_ENV)
xml_index.index.cache_clear()
@pytest.mark.parametrize(
("support", "expected"),
[
("drones", True),
("none", False),
("0901;090c;090e", True),
("0901;090e", False),
("090c", True),
("090c:4.3.0", True), # firmware 4.7.1 clears a 4.3.0 minimum
("090c:4.8.0", False),
("0901:2.0.29;090c;090e", True),
("0914:1.6.3;0919:1.6.3", False),
("090e;090c:4.3.0", True),
(None, None),
("", None),
(" ", None),
],
)
def test_supports_bebop2(support, expected):
assert xml_index.supports_bebop2(support) is expected
def test_support_versions_compare_as_integers_not_strings():
# Lexically "2.0.29" < "2.0.3", which would make this unsupported.
assert xml_index.supports_bebop2("090c:2.0.29", firmware=(2, 0, 3)) is False
assert xml_index.supports_bebop2("090c:2.0.3", firmware=(2, 0, 29)) is True
assert xml_index.supports_bebop2("090c:10.0.0", firmware=(9, 0, 0)) is False
def test_unknown_support_is_not_unsupported():
unknown = [c for c in xml_index.all_commands() if xml_index.supports_bebop2(c.support) is None]
# 30 commands carry a <comment> with no support attribute, 2 carry an empty
# one, 6 have no <comment> at all, and FlatTrim is hand-added.
assert len(unknown) == 39
assert xml_index.get("ardrone3.Piloting.FlatTrim") in unknown
+131
View File
@@ -0,0 +1,131 @@
"""Tiering decides what needs arming, so a misfiled command is a safety bug."""
import collections
from mcbebop.protocol import xml_index
from mcbebop.protocol.safety import ALWAYS_ALLOWED, tier_for
from mcbebop.protocol.types import Direction, Tier
MOTION = {
# The aircraft itself.
"ardrone3.Piloting.FlatTrim",
"ardrone3.Piloting.TakeOff",
"ardrone3.Piloting.PCMD",
"ardrone3.Piloting.Landing",
"ardrone3.Piloting.Emergency",
"ardrone3.Piloting.NavigateHome",
"ardrone3.Piloting.AutoTakeOffMode",
"ardrone3.Piloting.moveBy",
"ardrone3.Piloting.UserTakeOff",
"ardrone3.Piloting.Circle",
"ardrone3.Piloting.moveTo",
"ardrone3.Piloting.CancelMoveTo",
"ardrone3.Piloting.StartPilotedPOI",
"ardrone3.Piloting.StopPilotedPOI",
"ardrone3.Piloting.CancelMoveBy",
"ardrone3.Piloting.StartPilotedPOIV2",
"ardrone3.Animations.Flip",
# Running a stored flight plan moves the whole mission.
"common.Mavlink.Start",
"common.Mavlink.Pause",
"common.Mavlink.Stop",
# Calibration asks a powered aircraft to rotate.
"common.Calibration.MagnetoCalibration",
"common.Calibration.PitotCalibration",
"common.Animations.StartAnimation",
"common.Animations.StopAnimation",
"common.Animations.StopAllAnimations",
}
def test_motion_set_is_exactly_these():
tiered = {c.full_name for c in xml_index.all_commands() if c.tier is Tier.MOTION}
assert tiered == MOTION
def test_every_motion_command_is_sendable():
for name in MOTION:
command = xml_index.get(name)
assert command is not None, name
assert command.direction is Direction.TO_DRONE, name
def test_observe_is_the_two_dump_everything_commands():
sendable_observe = {
c.full_name
for c in xml_index.all_commands()
if c.tier is Tier.OBSERVE and c.direction is Direction.TO_DRONE
}
assert sendable_observe == {"common.Common.AllStates", "common.Settings.AllSettings"}
def test_envelope_covers_the_limit_and_radio_classes():
envelope = {c.full_name for c in xml_index.all_commands() if c.tier is Tier.ENVELOPE}
for name in (
"ardrone3.PilotingSettings.MaxAltitude",
"ardrone3.PilotingSettings.NoFlyOverMaxDistance",
"ardrone3.SpeedSettings.MaxVerticalSpeed",
"ardrone3.GPSSettings.SetHome",
"ardrone3.Network.WifiScan",
"ardrone3.NetworkSettings.WifiSelection",
"common.Network.Disconnect",
"common.WifiSettings.OutdoorSetting",
"common.FlightPlanSettings.ReturnHomeOnDisconnect",
"common.Settings.Country",
"common.Settings.AutoCountry",
"common.Settings.Reset",
"common.Common.Reboot",
"common.Factory.Reset",
"common.OverHeat.SwitchOff",
"common.Charger.SetMaxChargeRate",
"common.Controller.isPiloting",
):
assert name in envelope, name
assert len(envelope) == 44
def test_config_is_the_harmless_remainder():
config = {c.full_name for c in xml_index.all_commands() if c.tier is Tier.CONFIG}
assert len(config) == 31
for name in (
"ardrone3.MediaRecord.Picture",
"ardrone3.MediaStreaming.VideoEnable",
"ardrone3.PictureSettings.VideoFramerate",
"ardrone3.Camera.Orientation",
"common.Common.CurrentDate",
"common.Settings.ProductName",
"common.Headlights.intensity",
):
assert name in config, name
assert not any(
c.direction is Direction.FROM_DRONE for c in xml_index.all_commands() if c.tier is Tier.CONFIG
)
def test_sendable_tiers_account_for_every_to_drone_command():
counts = collections.Counter(
c.tier for c in xml_index.all_commands() if c.direction is Direction.TO_DRONE
)
assert counts[Tier.MOTION] == 25
assert counts[Tier.ENVELOPE] == 44
assert counts[Tier.OBSERVE] == 2
assert counts[Tier.CONFIG] == 31
assert sum(counts.values()) == 102 # 101 in the XML plus the injected FlatTrim
def test_events_are_observe():
for command in xml_index.all_commands():
if command.direction is Direction.FROM_DRONE:
assert command.tier is Tier.OBSERVE, command.full_name
def test_always_allowed_are_the_two_that_end_a_flight():
assert frozenset({"ardrone3.Piloting.Landing", "ardrone3.Piloting.Emergency"}) == ALWAYS_ALLOWED
for name in ALWAYS_ALLOWED:
command = xml_index.get(name)
assert command.tier is Tier.MOTION # locked by tier, exempted by name
def test_tier_for_is_what_the_index_stamped():
for command in xml_index.all_commands():
assert tier_for(command) is command.tier