Files
rsp2k ef8c4658e0 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.
2026-10-02 00:07:43 -06:00

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