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,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
|
||||
Reference in New Issue
Block a user