Protocol layer: XML index, codec, safety tiers, expectations
Parse Parrot's common.xml and ardrone3.xml into CommandSpec objects rather than transcribing 264 commands by hand. Direction is derived from comment result=/triggered= with a class-name-suffix fallback for the 31 commands that state neither, giving 101 to-drone and 163 from-drone. ardrone3.Piloting.FlatTrim is injected: the firmware accepts it but neither the vendored snapshot nor upstream defines it. codec encodes the <BBH header (the command id is 16 bits, not 8) and reads enums as i32 and strings as NUL-terminated, both of which pyparrot gets wrong. safety assigns the arming tier by class, with Mavlink and Calibration counted as motion because they move the aircraft. expectations parses the undocumented #p-c-m grammar including | alternatives and this.<arg> echoes. 90 tests, counts asserted so a bad parse fails loudly.
This commit is contained in:
@@ -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 == ()
|
||||
Reference in New Issue
Block a user