CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
Ardupilot

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.

GitHub Repository: Ardupilot/ardupilot
Path: blob/master/Tools/scripts/annotate_params.py
Views: 1798
1
#!/usr/bin/env python3
2
3
"""
4
This script fetches online ArduPilot parameter documentation (if not cached) and adds it to the specified file
5
or to all *.param and *.parm files in the specified directory.
6
7
1. Checks if a local cache of the XML file exists in the target directory or on the directory of the target file:
8
- If it does, the script loads the file content.
9
- If it doesn't, the script sends a GET request to the URL to fetch the XML data for the requested vehicle type.
10
2. Parses the XML data and creates a dictionary of parameter documentation.
11
3. DELETES all comments that start at the beginning of a line
12
4. Adds the parameter documentation to the target file or to all *.param,*.parm files in the target directory.
13
14
Supports AP_Periph, AntennaTracker, ArduCopter, ArduPlane, ArduSub, Blimp, Heli, Rover and SITL vehicle types
15
Supports both Mission Planner and MAVProxy file formats
16
Supports sorting the parameters
17
Has unit tests with 88% coverage
18
19
AP_FLAKE8_CLEAN
20
21
Author: Amilcar do Carmo Lucas, IAV GmbH
22
"""
23
24
import os
25
import glob
26
import re
27
from typing import Any, Dict, List, Tuple
28
import xml.etree.ElementTree as ET
29
import argparse
30
import logging
31
32
# URL of the XML file
33
BASE_URL = "https://autotest.ardupilot.org/Parameters/"
34
35
PARAM_DEFINITION_XML_FILE = "apm.pdef.xml"
36
37
# ArduPilot parameter names start with a capital letter and can have capital letters, numbers and _
38
PARAM_NAME_REGEX = r'^[A-Z][A-Z_0-9]*'
39
PARAM_NAME_MAX_LEN = 16
40
VERSION = '1.0'
41
42
43
def arg_parser():
44
parser = argparse.ArgumentParser(description='Fetches on-line ArduPilot parameter documentation and adds it to the '
45
'specified file or to all *.param and *.parm files in the specified directory.')
46
parser.add_argument('target',
47
help='The target file or directory.',
48
)
49
parser.add_argument('-s', '--sort',
50
choices=['none', 'missionplanner', 'mavproxy'],
51
default='none',
52
help='Sort the parameters in the file. Defaults to not sorting.',
53
)
54
parser.add_argument('-t', '--vehicle-type',
55
choices=['AP_Periph', 'AntennaTracker', 'ArduCopter', 'ArduPlane',
56
'ArduSub', 'Blimp', 'Heli', 'Rover', 'SITL'],
57
default='ArduCopter',
58
help='The type of the vehicle. Defaults to ArduCopter',
59
)
60
parser.add_argument('--verbose', action='store_true',
61
help='Increase output verbosity, print ReadOnly parameter list. Defaults to false',
62
)
63
parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {VERSION}',
64
help='Display version information and exit.',
65
)
66
args = parser.parse_args()
67
68
if args.verbose:
69
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
70
else:
71
logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s')
72
73
return args
74
75
76
def get_xml_data(base_url: str, directory: str, filename: str) -> ET.Element:
77
"""
78
Fetches XML data from a local file or a URL.
79
80
Args:
81
base_url (str): The base URL for fetching the XML file.
82
directory (str): The directory where the XML file is expected.
83
filename (str): The name of the XML file.
84
85
Returns:
86
ET.Element: The root element of the parsed XML data.
87
"""
88
file_path = os.path.join(directory, filename)
89
# Check if the locally cached file exists
90
if os.path.isfile(file_path):
91
# Load the file content relative to the script location
92
with open(file_path, "r", encoding="utf-8") as file:
93
xml_data = file.read()
94
else:
95
# No locally cached file exists, get it from the internet
96
try:
97
import requests # pylint: disable=C0415
98
except ImportError:
99
logging.error("The requests package was not found")
100
logging.error("Please install it by running 'pip install requests' in your terminal.")
101
raise SystemExit("requests package is not installed") # pylint: disable=W0707
102
try:
103
# Send a GET request to the URL
104
response = requests.get(base_url + filename, timeout=5)
105
except requests.exceptions.RequestException as e:
106
logging.error("Unable to fetch XML data: %s", e)
107
raise SystemExit("unable to fetch online XML documentation") # pylint: disable=W0707
108
# Get the text content of the response
109
xml_data = response.text
110
# Write the content to a file
111
with open(os.path.join(directory, filename), "w", encoding="utf-8") as file:
112
file.write(xml_data)
113
114
# Parse the XML data
115
root = ET.fromstring(xml_data)
116
return root
117
118
119
def remove_prefix(text: str, prefix: str) -> str:
120
"""
121
Removes a prefix from a string.
122
123
Args:
124
text (str): The original string.
125
prefix (str): The prefix to remove.
126
127
Returns:
128
str: The string without the prefix.
129
"""
130
if text.startswith(prefix):
131
return text[len(prefix):]
132
return text
133
134
135
def split_into_lines(string_to_split: str, maximum_line_length: int) -> List[str]:
136
"""
137
Splits a string into lines of a maximum length.
138
139
Args:
140
string_to_split (str): The string to split.
141
maximum_line_length (int): The maximum length of a line.
142
143
Returns:
144
List[str]: The list of lines.
145
"""
146
doc_lines = re.findall(
147
r".{1," + str(maximum_line_length) + r"}(?:\s|$)", string_to_split
148
)
149
# Remove trailing whitespace from each line
150
return [line.rstrip() for line in doc_lines]
151
152
153
def create_doc_dict(root: ET.Element, vehicle_type: str) -> Dict[str, Any]:
154
"""
155
Create a dictionary of parameter documentation from the root element of the parsed XML data.
156
157
Args:
158
root (ET.Element): The root element of the parsed XML data.
159
160
Returns:
161
Dict[str, Any]: A dictionary of parameter documentation.
162
"""
163
# Dictionary to store the parameter documentation
164
doc = {}
165
166
# Use the findall method with an XPath expression to find all "param" elements
167
for param in root.findall(".//param"):
168
name = param.get("name")
169
# Remove the <vehicle_type>: prefix from the name if it exists
170
name = remove_prefix(name, vehicle_type + ":")
171
172
human_name = param.get("humanName")
173
documentation = split_into_lines(param.get("documentation"), 100)
174
# the keys are the "name" attribute of the "field" sub-elements
175
# the values are the text content of the "field" sub-elements
176
fields = {field.get("name"): field.text for field in param.findall("field")}
177
# if Units and UnitText exist, combine them into a single element
178
delete_unit_text = False
179
for key, value in fields.items():
180
if key == "Units" and "UnitText" in fields:
181
fields[key] = f"{value} ({fields['UnitText']})"
182
delete_unit_text = True
183
if delete_unit_text:
184
del fields['UnitText']
185
# the keys are the "code" attribute of the "values/value" sub-elements
186
# the values are the text content of the "values/value" sub-elements
187
values = {value.get("code"): value.text for value in param.findall("values/value")}
188
189
# Dictionary with "Parameter names" as keys and the values is a
190
# dictionary with "humanName", "documentation" attributes and
191
# "fields", "values" sub-elements.
192
doc[name] = {
193
"humanName": human_name,
194
"documentation": documentation,
195
"fields": fields,
196
"values": values,
197
}
198
199
return doc
200
201
202
def format_columns(values: Dict[str, Any], max_width: int = 105, max_columns: int = 4) -> List[str]:
203
"""
204
Formats a dictionary of values into column-major horizontally aligned columns.
205
It uses at most max_columns columns
206
207
Args:
208
values (Dict[str, Any]): The dictionary of values to format.
209
max_width (int, optional): The maximum number of characters on all columns. Defaults to 105.
210
211
Returns:
212
List[str]: The list of formatted strings.
213
"""
214
# Convert the dictionary into a list of strings
215
strings = [f"{k}: {v}" for k, v in values.items()]
216
217
if (not strings) or (len(strings) == 0):
218
return []
219
220
# Calculate the maximum length of the strings
221
max_len = max(len(s) for s in strings)
222
223
# Determine the number of columns
224
# Column distribution will only happen if it results in more than 5 rows
225
# The strings will be distributed evenly across up-to max_columns columns.
226
for num_cols in range(max_columns, 0, -1):
227
if len(strings) // num_cols > 5 and (max_len + 2) * num_cols < max_width:
228
break
229
230
# Calculate the column width
231
col_width = max_width // num_cols
232
233
num_rows = (len(strings) + num_cols - 1) // num_cols
234
235
formatted_strings = []
236
for j in range(num_rows):
237
row = []
238
for i in range(num_cols):
239
if i*num_rows + j < len(strings):
240
if i < num_cols - 1 and ((i+1)*num_rows + j < len(strings)):
241
row.append(strings[i*num_rows + j].ljust(col_width))
242
else:
243
row.append(strings[i*num_rows + j])
244
formatted_strings.append(" ".join(row))
245
246
return formatted_strings
247
248
249
def extract_parameter_name(item: str) -> str:
250
"""
251
Extract the parameter name from a line. Very simple to be used in sorting
252
"""
253
item = item.strip()
254
match = re.match(PARAM_NAME_REGEX, item)
255
return match.group(0) if match else item
256
257
258
def missionplanner_sort(item: str) -> Tuple[str, ...]:
259
"""
260
MissionPlanner parameter sorting function
261
"""
262
# Split the parameter name by underscore
263
parts = extract_parameter_name(item).split("_")
264
# Compare the parts separately
265
return tuple(parts)
266
267
268
def extract_parameter_name_and_validate(line: str, filename: str, line_nr: int) -> str:
269
"""
270
Extracts the parameter name from a line and validates it.
271
Args:
272
line (str): The line to extract the parameter name from.
273
Returns:
274
str: The extracted parameter name.
275
Raises:
276
SystemExit: If the line is invalid or the parameter name is too long or invalid.
277
"""
278
# Extract the parameter name
279
match = re.match(PARAM_NAME_REGEX, line)
280
if match:
281
param_name = match.group(0)
282
else:
283
logging.error("Invalid line %d in file %s: %s", line_nr, filename, line)
284
raise SystemExit("Invalid line in input file")
285
param_len = len(param_name)
286
param_sep = line[param_len] # the character following the parameter name must be a separator
287
if param_sep not in {',', ' ', '\t'}:
288
logging.error("Invalid parameter name %s on line %d in file %s", param_name, line_nr,
289
filename)
290
raise SystemExit("Invalid parameter name")
291
if param_len > PARAM_NAME_MAX_LEN:
292
logging.error("Too long parameter name on line %d in file %s", line_nr, filename)
293
raise SystemExit("Too long parameter name")
294
return param_name
295
296
297
def update_parameter_documentation(doc: Dict[str, Any], target: str = '.', sort_type: str = 'none') -> None:
298
"""
299
Updates the parameter documentation in the target file or in all *.param,*.parm files of the target directory.
300
301
This function iterates over all the ArduPilot parameter files in the target directory or file.
302
For each file, it DELETES all comments that start at the beginning of a line, optionally sorts the
303
parameter names and checks if the parameter name is in the dictionary of parameter documentation.
304
If it is, it prefixes the line with comment derived from the dictionary element.
305
If it's not, it copies the parameter line 1-to-1.
306
After processing all the parameters in a file, it writes the new lines back to the file.
307
308
Args:
309
doc (Dict[str, Any]): A dictionary of parameter documentation.
310
target (str, optional): The target directory or file. Defaults to '.'.
311
sort_type (str, optional): The type of sorting to apply to the parameters.
312
Can be 'none', 'missionplanner', or 'mavproxy'. Defaults to 'none'.
313
"""
314
# Check if the target is a file or a directory
315
if os.path.isfile(target):
316
# If it's a file, process only that file
317
param_files = [target]
318
elif os.path.isdir(target):
319
# If it's a directory, process all .param and .parm files in that directory
320
param_files = glob.glob(os.path.join(target, "*.param")) \
321
+ glob.glob(os.path.join(target, "*.parm"))
322
else:
323
raise ValueError(f"Target '{target}' is neither a file nor a directory.")
324
325
# Iterate over all the target ArduPilot parameter files
326
for param_file in param_files:
327
328
# Read the entire file contents
329
with open(param_file, "r", encoding="utf-8") as file:
330
lines = file.readlines()
331
332
new_lines = []
333
total_params = 0
334
documented_params = 0
335
undocumented_params = []
336
is_first_param_in_file = True # pylint: disable=C0103
337
if sort_type == "missionplanner":
338
lines.sort(key=missionplanner_sort)
339
if sort_type == "mavproxy":
340
lines.sort(key=extract_parameter_name)
341
for n, line in enumerate(lines, start=1):
342
line = line.strip()
343
if not line.startswith("#") and line:
344
param_name = extract_parameter_name_and_validate(line, param_file, n)
345
346
if param_name in doc:
347
# If the parameter name is in the dictionary,
348
# prefix the line with comment derived from the dictionary element
349
data = doc[param_name]
350
prefix_parts = [
351
f"{data['humanName']}",
352
]
353
prefix_parts += data["documentation"]
354
for key, value in data["fields"].items():
355
prefix_parts.append(f"{key}: {value}")
356
prefix_parts += format_columns(data["values"])
357
doc_text = "\n# ".join(prefix_parts) # pylint: disable=C0103
358
if not is_first_param_in_file:
359
new_lines.append("\n")
360
new_lines.append(f"# {doc_text}\n{line}\n")
361
documented_params += 1
362
else:
363
# If the parameter name is in not the dictionary, copy the parameter line 1-to-1
364
new_lines.append(f"{line}\n")
365
undocumented_params.append(param_name)
366
total_params += 1
367
is_first_param_in_file = False
368
369
if total_params == documented_params:
370
logging.info("Read file %s with %d parameters, all got documented",
371
param_file, total_params)
372
else:
373
logging.warning("Read file %s with %d parameters, but only %s of which got documented",
374
param_file, total_params, documented_params)
375
logging.warning("No documentation found for: %s", ", ".join(undocumented_params))
376
377
# Write the new file contents to the file
378
with open(param_file, "w", encoding="utf-8") as file:
379
file.writelines(new_lines)
380
381
382
def print_read_only_params(doc):
383
"""
384
Print the names of read-only parameters.
385
386
Args:
387
doc (dict): A dictionary of parameter documentation.
388
"""
389
logging.info("ReadOnly parameters:")
390
for param_name, param_value in doc.items():
391
if 'ReadOnly' in param_value['fields'] and param_value['fields']['ReadOnly']:
392
logging.info(param_name)
393
394
395
def main():
396
args = arg_parser()
397
try:
398
xml_dir = args.target if os.path.isdir(args.target) else os.path.dirname(os.path.realpath(args.target))
399
xml_root = get_xml_data(BASE_URL + args.vehicle_type + "/", xml_dir, PARAM_DEFINITION_XML_FILE)
400
doc_dict = create_doc_dict(xml_root, args.vehicle_type)
401
update_parameter_documentation(doc_dict, args.target, args.sort)
402
if args.verbose:
403
print_read_only_params(doc_dict)
404
except Exception as exp: # pylint: disable=W0718
405
logging.fatal(exp)
406
exit(1) # pylint: disable=R1722
407
408
409
if __name__ == "__main__":
410
main()
411
412