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.
132 lines
4.6 KiB
Python
132 lines
4.6 KiB
Python
"""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
|