Skip to content

Commit 52b2a46

Browse files
committed
Add support for MAV_CMD_DO_ORBIT
1 parent 2a7a8d2 commit 52b2a46

4 files changed

Lines changed: 341 additions & 0 deletions

File tree

MAVProxy/modules/lib/mp_menu.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,124 @@ def call(self):
450450
# Return tuple with text value and selected dropdown value
451451
return text_value + " " + dropdown_value
452452

453+
class MPMenuCallMultiTextDropdownDialog(object):
454+
'''used to create a dialog callback with multiple values and optional dropdowns.
455+
456+
Each field is a (label, default) tuple, and each dropdown a
457+
(label, options, default) tuple. An extra trailing element - a key -
458+
may be added to either (i.e. (label, default, key) or
459+
(label, options, default, key)); when present the corresponding value
460+
is emitted as "key=value", otherwise the bare value is emitted.
461+
'''
462+
def __init__(self, title='Enter Values', fields=None, dropdowns=None):
463+
self.title = title
464+
self.fields = fields or []
465+
self.dropdowns = dropdowns or []
466+
467+
@classmethod
468+
def from_arg_spec(cls, title, spec, ctx=None):
469+
'''build a dialog from a command argument spec.
470+
471+
spec is an ordered dict of key->facets (see
472+
MPModule.parse_key_value_spec). Each entry that carries a 'label'
473+
becomes a widget emitting "key=value": a dropdown when it has an
474+
'options' list, otherwise a text field. Entries without a 'label'
475+
(e.g. keys entered another way) are skipped. A 'default' facet may
476+
be a plain value or a callable which is passed ctx to produce a
477+
live default.
478+
'''
479+
fields = []
480+
dropdowns = []
481+
for (key, facets) in spec.items():
482+
if 'label' not in facets:
483+
continue
484+
default = facets.get('default', '')
485+
if callable(default):
486+
default = default(ctx)
487+
if 'options' in facets:
488+
dropdowns.append((facets['label'], facets['options'], default, key))
489+
else:
490+
fields.append((facets['label'], default, key))
491+
return cls(title=title, fields=fields, dropdowns=dropdowns)
492+
493+
def call(self):
494+
'''show a dialog with multiple value entries and optional dropdowns'''
495+
from MAVProxy.modules.lib.wx_loader import wx
496+
497+
# Create a custom dialog
498+
dlg = wx.Dialog(None, title=self.title, size=(400, 150))
499+
500+
# Create a vertical box sizer for the dialog
501+
main_sizer = wx.BoxSizer(wx.VERTICAL)
502+
503+
# One row of label plus text entry per field
504+
text_ctrls = []
505+
for field in self.fields:
506+
(label, default) = field[0], field[1]
507+
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
508+
text_label = wx.StaticText(dlg, label=label + ":")
509+
memory_key = self.title + ":" + label
510+
default = last_value_selection.get(memory_key, default)
511+
text_ctrl = wx.TextCtrl(dlg, value=str(default), size=(200, -1))
512+
text_ctrls.append(text_ctrl)
513+
input_sizer.Add(text_label, 1, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
514+
input_sizer.Add(text_ctrl, 0, wx.ALL | wx.EXPAND, 5)
515+
main_sizer.Add(input_sizer, 0, wx.ALL | wx.EXPAND, 5)
516+
517+
# One row of label plus choice control per dropdown
518+
dropdown_ctrls = []
519+
for dropdown in self.dropdowns:
520+
(label, options, default) = dropdown[0], dropdown[1], dropdown[2]
521+
input_sizer = wx.BoxSizer(wx.HORIZONTAL)
522+
dropdown_label = wx.StaticText(dlg, label=label)
523+
dropdown_ctrl = wx.Choice(dlg, choices=options)
524+
dropdown_ctrls.append(dropdown_ctrl)
525+
526+
default_idx = 0
527+
for i in range(len(options)):
528+
if options[i] == default:
529+
default_idx = i
530+
memory_key = self.title + ":" + label
531+
dropdown_ctrl.SetSelection(last_dropdown_selection.get(memory_key, default_idx))
532+
533+
input_sizer.Add(dropdown_label, 1, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
534+
input_sizer.Add(dropdown_ctrl, 0, wx.ALL | wx.EXPAND, 5)
535+
main_sizer.Add(input_sizer, 0, wx.ALL | wx.EXPAND, 5)
536+
537+
# Create button sizer with OK and Cancel buttons
538+
button_sizer = dlg.CreateButtonSizer(wx.OK | wx.CANCEL)
539+
main_sizer.Add(button_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10)
540+
541+
# Set the sizer for the dialog
542+
dlg.SetSizer(main_sizer)
543+
544+
# Fit the dialog to its contents
545+
dlg.Fit()
546+
547+
# Show the dialog and get the result
548+
if dlg.ShowModal() != wx.ID_OK:
549+
return None
550+
551+
values = []
552+
for (field, text_ctrl) in zip(self.fields, text_ctrls):
553+
label = field[0]
554+
key = field[2] if len(field) > 2 else None
555+
text_value = text_ctrl.GetValue()
556+
last_value_selection[self.title + ":" + label] = text_value
557+
values.append("%s=%s" % (key, text_value) if key else text_value)
558+
559+
for (dropdown, dropdown_ctrl) in zip(self.dropdowns, dropdown_ctrls):
560+
(label, options) = dropdown[0], dropdown[1]
561+
key = dropdown[3] if len(dropdown) > 3 else None
562+
dropdown_index = dropdown_ctrl.GetSelection()
563+
if dropdown_index != -1:
564+
dropdown_value = options[dropdown_index]
565+
last_dropdown_selection[self.title + ":" + label] = dropdown_index
566+
values.append("%s=%s" % (key, dropdown_value) if key else dropdown_value)
567+
568+
# Return the entered values, space-separated, dropdown values last
569+
return " ".join(values)
570+
453571
class MPMenuConfirmDialog(object):
454572
'''used to create a confirmation dialog'''
455573
def __init__(self, title='Confirmation', message='', callback=None, args=None):

MAVProxy/modules/lib/mp_module.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,82 @@ def remove_command(self, name):
157157
def add_completion_function(self, name, callback):
158158
self.mpstate.completion_functions[name] = callback
159159

160+
def parse_key_value_args(self, args, valid_keys, values):
161+
'''split "key=value" arguments into the values dict.
162+
163+
valid_keys maps each accepted (lower-case) key to a callable used
164+
to convert its value (e.g. int, float, str), so values ends up
165+
holding correctly-typed entries. Returns True on success, or
166+
prints an error and returns False for an argument which is not in
167+
key=value form, an unknown key, or a value which will not convert.
168+
'''
169+
for arg in args:
170+
if '=' not in arg:
171+
print("Argument '%s' is not in key=value form" % arg)
172+
return False
173+
(key, value) = arg.split('=', 1)
174+
key = key.lower()
175+
if key not in valid_keys:
176+
print("Unknown argument '%s'" % key)
177+
return False
178+
try:
179+
values[key] = valid_keys[key](value)
180+
except ValueError:
181+
print("Invalid value '%s' for argument '%s'" % (value, key))
182+
return False
183+
return True
184+
185+
def parse_key_value_spec(self, args, spec, values):
186+
'''parse "key=value" arguments according to an argument spec.
187+
188+
spec is an ordered dict mapping each canonical key to a dict of
189+
facets; the facets used here are:
190+
type callable used to convert the value (required)
191+
synonyms list of alternative spellings folded onto this key
192+
required if True, the key must be supplied
193+
Converted, synonym-folded values are stored in the values dict.
194+
Returns True on success, or prints an error and returns False.
195+
See parse_key_value_args() and format_key_value_help().
196+
'''
197+
valid_keys = {}
198+
for (key, facets) in spec.items():
199+
valid_keys[key] = facets['type']
200+
for synonym in facets.get('synonyms', []):
201+
valid_keys[synonym] = facets['type']
202+
203+
if not self.parse_key_value_args(args, valid_keys, values):
204+
return False
205+
206+
# fold each synonym onto its canonical key
207+
for (key, facets) in spec.items():
208+
for synonym in facets.get('synonyms', []):
209+
if synonym in values:
210+
values[key] = values.pop(synonym)
211+
212+
for (key, facets) in spec.items():
213+
if facets.get('required') and key not in values:
214+
print("%s is required" % key)
215+
return False
216+
return True
217+
218+
def format_key_value_help(self, spec):
219+
'''return a list of aligned description lines for an argument spec.
220+
221+
Each line describes one key (with any synonyms) and its help text,
222+
marking required keys. See parse_key_value_spec().
223+
'''
224+
names = {}
225+
for (key, facets) in spec.items():
226+
names[key] = '/'.join([key] + facets.get('synonyms', []))
227+
width = max(len(n) for n in names.values())
228+
lines = []
229+
for (key, facets) in spec.items():
230+
text = facets.get('help', '')
231+
if facets.get('required'):
232+
text += ' (required)'
233+
lines.append(" %-*s %s" % (width, names[key], text))
234+
return lines
235+
160236
def flyto_frame_units(self):
161237
'''return a frame string and unit'''
162238
return "%s %s" % (self.settings.height_unit, self.settings.flytoframe)

MAVProxy/modules/mavproxy_map/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from MAVProxy.modules.lib import mp_settings
1515
from MAVProxy.modules.lib import mp_module
1616
from MAVProxy.modules.lib.mp_menu import *
17+
from MAVProxy.modules.mavproxy_mode import orbit_arg_spec
1718
from pymavlink import mavutil
1819
from PIL import ImageColor
1920

@@ -120,6 +121,15 @@ def __init__(self, mpstate):
120121
default_dropdown=self.settings.flytoframe,
121122
)
122123
))
124+
self.add_menu(MPMenuItem(
125+
'Orbit Here', 'Orbit Here', '# orbit ',
126+
handler=MPMenuCallMultiTextDropdownDialog.from_arg_spec(
127+
'Orbit', orbit_arg_spec,
128+
ctx={
129+
'guidedalt': self.mpstate.settings.guidedalt,
130+
'flytoframe': self.settings.flytoframe,
131+
})
132+
))
123133
self.add_menu(MPMenuItem('Terrain Check', 'Terrain Check', '# terrain check'))
124134
self.add_menu(MPMenuItem('Show Position', 'Show Position', 'showPosition'))
125135
self.add_menu(MPMenuItem('Google Maps Link', 'Google Maps Link', 'printGoogleMapsLink'))

MAVProxy/modules/mavproxy_mode.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env python3
22
'''mode command handling'''
33

4+
import collections
5+
import math
6+
47
from pymavlink import mavutil
58

69
from MAVProxy.modules.lib import mp_module
@@ -10,6 +13,52 @@
1013
AP_FLAKE8_CLEAN
1114
'''
1215

16+
# names for MAV_CMD_DO_ORBIT param3, ORBIT_YAW_BEHAVIOUR values; NaN
17+
# leaves the choice to the vehicle
18+
orbit_yaw_behaviours = {
19+
"Default": float('NaN'),
20+
"FaceCentre": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_HOLD_FRONT_TO_CIRCLE_CENTER,
21+
"InitialHeading": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_HOLD_INITIAL_HEADING,
22+
"Uncontrolled": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_UNCONTROLLED,
23+
"Tangent": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_HOLD_FRONT_TANGENT_TO_CIRCLE,
24+
"RCControlled": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_RC_CONTROLLED,
25+
"Unchanged": mavutil.mavlink.ORBIT_YAW_BEHAVIOUR_UNCHANGED,
26+
}
27+
28+
orbit_frames = ['AboveHome', 'AGL', 'AMSL']
29+
30+
# single source of truth for the "orbit" command arguments: drives
31+
# command-line parsing, command-line help, and the map "Orbit Here" popup
32+
# dialog. See MPModule.parse_key_value_spec / format_key_value_help and
33+
# MPMenuCallMultiTextDropdownDialog.from_arg_spec for the facets consumed.
34+
# Keys without a 'label' (loc/lat/lng) are command-line only: the popup
35+
# takes its centre from the map click.
36+
orbit_arg_spec = collections.OrderedDict([
37+
('radius', dict(type=float, required=True,
38+
help='metres, positive for clockwise, negative for counter-clockwise',
39+
label='Radius (m, -ve for CCW)', default=50)),
40+
('alt', dict(type=float, synonyms=['altitude'],
41+
help='orbit altitude',
42+
label='Altitude', default=lambda ctx: ctx['guidedalt'])),
43+
('velocity', dict(type=str,
44+
help="tangential velocity in m/s, 'default' for the vehicle default",
45+
label='Velocity (m/s)', default='Default')),
46+
('orbits', dict(type=float,
47+
help='number of circuits to fly, 0 to orbit forever',
48+
label='Orbits (0 for forever)', default=0)),
49+
('yaw', dict(type=str, options=list(orbit_yaw_behaviours.keys()),
50+
help='one of %s or an ORBIT_YAW_BEHAVIOUR enumeration value' %
51+
'|'.join(orbit_yaw_behaviours.keys()),
52+
label='Yaw Behaviour', default='Default')),
53+
('frame', dict(type=str, options=orbit_frames,
54+
help='altitude frame',
55+
label='Frame', default=lambda ctx: ctx['flytoframe'])),
56+
('loc', dict(type=str,
57+
help='orbit centre as LAT,LNG or LAT,LNG,ALT; defaults to the map click position')),
58+
('lat', dict(type=float, synonyms=['latitude'], help='orbit centre latitude')),
59+
('lng', dict(type=float, synonyms=['longitude', 'lon'], help='orbit centre longitude')),
60+
])
61+
1362

1463
class ModeModule(mp_module.MPModule):
1564
def __init__(self, mpstate):
@@ -18,6 +67,7 @@ def __init__(self, mpstate):
1867
'(MODE)'
1968
])
2069
self.add_command('guided', self.cmd_guided, "fly to a clicked location on map")
70+
self.add_command('orbit', self.cmd_orbit, "orbit around a clicked location on map")
2171
self.add_command('confirm', self.cmd_confirm, "confirm a command")
2272
self.add_completion_function('(MODE)', self.complete_available_modes)
2373

@@ -140,6 +190,93 @@ def cmd_guided(self, args):
140190
altitude
141191
)
142192

193+
def cmd_orbit_usage(self):
194+
'''print usage for the orbit command'''
195+
print("Usage: orbit radius=RADIUS [alt=ALTITUDE] [velocity=VELOCITY] "
196+
"[orbits=ORBITS] [yaw=YAW] [frame=%s]" % '|'.join(orbit_frames))
197+
print(" [loc=LAT,LNG | lat=LAT lng=LNG]")
198+
for line in self.format_key_value_help(orbit_arg_spec):
199+
print(line)
200+
201+
def cmd_orbit(self, args):
202+
'''send MAV_CMD_DO_ORBIT to orbit around the clicked location'''
203+
values = {}
204+
if not self.parse_key_value_spec(args, orbit_arg_spec, values):
205+
self.cmd_orbit_usage()
206+
return
207+
208+
# work out the orbit centre: an explicit location if given, else the map click
209+
if 'loc' in values:
210+
if 'lat' in values or 'lng' in values:
211+
print("specify either loc= or lat=/lng=, not both")
212+
return
213+
parts = values['loc'].split(',')
214+
try:
215+
if len(parts) not in (2, 3):
216+
raise ValueError
217+
latlon = (float(parts[0]), float(parts[1]))
218+
if len(parts) == 3 and 'alt' not in values:
219+
values['alt'] = float(parts[2])
220+
except ValueError:
221+
print("loc must be LAT,LNG or LAT,LNG,ALT")
222+
return
223+
elif 'lat' in values or 'lng' in values:
224+
if 'lat' not in values or 'lng' not in values:
225+
print("both lat and lng are required")
226+
return
227+
latlon = (values['lat'], values['lng'])
228+
else:
229+
latlon = self.mpstate.click_location
230+
if latlon is None:
231+
print("No map click position available")
232+
return
233+
234+
if 'frame' in values:
235+
if values['frame'] not in orbit_frames:
236+
print("frame must be one of %s" % '|'.join(orbit_frames))
237+
return
238+
self.settings.flytoframe = values['frame']
239+
240+
radius = values['radius']
241+
if 'alt' in values:
242+
altitude = values['alt']
243+
else:
244+
altitude = self.mpstate.settings.guidedalt
245+
altitude = self.height_convert_from_units(altitude)
246+
velocity = float('NaN') # vehicle default velocity
247+
if 'velocity' in values and values['velocity'].lower() != 'default':
248+
velocity = float(values['velocity'])
249+
orbits = 0 # orbit forever
250+
if 'orbits' in values:
251+
orbits = values['orbits']
252+
yaw_behaviour = float('NaN') # vehicle default yaw behaviour
253+
if 'yaw' in values:
254+
behaviours_lower = {k.lower(): v for (k, v) in orbit_yaw_behaviours.items()}
255+
if values['yaw'].lower() in behaviours_lower:
256+
yaw_behaviour = behaviours_lower[values['yaw'].lower()]
257+
else:
258+
yaw_behaviour = float(values['yaw'])
259+
260+
frame = self.flyto_frame()
261+
262+
print("Orbit %s radius %.1fm alt %.1f frame %u" % (str(latlon), radius, altitude, frame))
263+
264+
self.master.mav.command_int_send(
265+
self.settings.target_system,
266+
self.settings.target_component,
267+
frame,
268+
mavutil.mavlink.MAV_CMD_DO_ORBIT,
269+
0, # current
270+
0, # autocontinue
271+
radius, # p1 - radius, +ve clockwise, -ve counter-clockwise
272+
velocity, # p2 - tangential velocity, NaN is use-default
273+
yaw_behaviour, # p3 - yaw behaviour, NaN is vehicle default
274+
orbits * 2 * math.pi, # p4 - angle to orbit in radians, 0 for forever
275+
int(latlon[0]*1.0e7),
276+
int(latlon[1]*1.0e7),
277+
altitude
278+
)
279+
143280
def build_pt_ignoremask(self, bits, force_not_accel=False):
144281
'''creates an ignore bitmask which ignores all bits except the ones passed in'''
145282
ignore_bits = {

0 commit comments

Comments
 (0)