#!/usr/bin/python3 """ This Python script fetches on-line ArduPilot parameter documentation and adds it to all *.param and *.parm files in the current directory. The script first fetches the XML file from a URL and parses it. It then creates a dictionary of the parameter documentation. The script then opens each parameter file in the current directory, reads the lines, adds documentation in the form of a comment block on top of each parameter, and then writes the new lines back to the file. The algorithm checks if each line starts with "#". If it does not, it extracts the parameter name from the line and checks if the parameter name is in the dictionary. If it is, it prefixes the line with the corresponding value from the dictionary. If it is not, it simply adds the line to the new lines. """ import os import glob import re from typing import Any, Dict, List import xml.etree.ElementTree as ET import argparse import logging # URL of the XML file BASE_URL = "https://autotest.ardupilot.org/Parameters/" PARAM_DEFINITION_XML_FILE = "apm.pdef.xml" SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) def get_xml_data(base_url: str, filename: str) -> ET.Element: """ Fetches XML data from a local file or a URL. Args: base_url (str): The base URL for fetching the XML file. filename (str): The name of the XML file. Returns: ET.Element: The root element of the parsed XML data. """ # Check if the locally cached file exists if os.path.isfile(filename): # Load the file content relative to cwd with open(filename, "r", encoding="utf-8") as file: xml_data = file.read() elif os.path.isfile(os.path.join(SCRIPT_DIR, filename)): # Load the file content relative to the script location with open(os.path.join(SCRIPT_DIR, filename), "r", encoding="utf-8") as file: xml_data = file.read() else: # No locally cached file exists, get it from the internet try: import requests # pylint: disable=C0415 except ImportError: logging.error("The requests package was not found") logging.error("Please install it by running 'pip install requests' in your terminal.") exit(1) # pylint: disable=R1722 try: # Send a GET request to the URL response = requests.get(base_url + filename, timeout=5) except requests.exceptions.RequestException as e: logging.error("Unable to fetch XML data: %s", e) exit(1) # pylint: disable=R1722 # Get the text content of the response xml_data = response.text # Write the content to a file with open(filename, "w", encoding="utf-8") as file: file.write(xml_data) # Parse the XML data root = ET.fromstring(xml_data) return root def remove_prefix(text: str, prefix: str) -> str: """ Removes a prefix from a string. Args: text (str): The original string. prefix (str): The prefix to remove. Returns: str: The string without the prefix. """ if text.startswith(prefix): return text[len(prefix):] return text def split_into_lines(string_to_split: str, maximum_line_length: int) -> List[str]: """ Splits a string into lines of a maximum length. Args: string_to_split (str): The string to split. maximum_line_length (int): The maximum length of a line. Returns: List[str]: The list of lines. """ doc_lines = re.findall( r".{1," + str(maximum_line_length) + r"}(?:\s|$)", string_to_split ) # Remove trailing whitespace from each line return [line.rstrip() for line in doc_lines] def create_doc_dict(root: ET.Element, vehicle_type: str) -> Dict[str, Any]: """ Create a dictionary of parameter documentation from the root element of the parsed XML data. Args: root (ET.Element): The root element of the parsed XML data. Returns: Dict[str, Any]: A dictionary of parameter documentation. """ # Dictionary to store the parameter documentation doc = {} # Use the findall method with an XPath expression to find all "param" elements for param in root.findall(".//param"): name = param.get("name") # Remove the : prefix from the name if it exists name = remove_prefix(name, vehicle_type + ":") human_name = param.get("humanName") documentation = split_into_lines(param.get("documentation"), 100) # the keys are the "name" attribute of the "field" sub-elements # the values are the text content of the "field" sub-elements fields = {field.get("name"): field.text for field in param.findall("field")} # if Units and UnitText exist, combine them into a single element delete_unit_text = False for key, value in fields.items(): if key == "Units" and "UnitText" in fields: fields[key] = f"{value} ({fields['UnitText']})" delete_unit_text = True if delete_unit_text: del fields['UnitText'] # the keys are the "code" attribute of the "values/value" sub-elements # the values are the text content of the "values/value" sub-elements values = {value.get("code"): value.text for value in param.findall("values/value")} # Dictionary with "Parameter names" as keys and the values is a # dictionary with "humanName", "documentation" attributes and # "fields", "values" sub-elements. doc[name] = { "humanName": human_name, "documentation": documentation, "fields": fields, "values": values, } return doc def update_parameter_documentation(doc: Dict[str, Any]) -> None: """ Updates the parameter documentation in all *.param,*.parm files in the current directory. Args: doc (Dict[str, Any]): A dictionary of parameter documentation. """ # Get all the *.param and *.parm files in the current directory param_files = glob.glob("*.param") + glob.glob("*.parm") # Iterate over all the ArduPilot parameter files for param_file in param_files: if os.path.basename(param_file).endswith("Magnetometer_Calibration.param"): continue # Open the file with open(param_file, "r", encoding="utf-8") as file: lines = file.readlines() new_lines = [] total_params = 0 documented_params = 0 undocumented_params = [] is_first_param_in_file = True # pylint: disable=C0103 for n, line in enumerate(lines, start=1): if not line.startswith("#") and line.strip(): # Extract the parameter name if line.count(","): # parse mission planner style parameter files param_name = line.split(",")[0] elif line.count(" "): # parse mavproxy style parameter files param_name = line.split(" ")[0] else: logging.error("Invalid line %d in file %s", n, param_file) exit(1) # pylint: disable=R1722 param_name = param_name.strip() if len(param_name) > 16: logging.error("Too long parameter name on line %d in file %s", n, param_file) exit(1) # pylint: disable=R1722 if not re.match(r'^[A-Z][A-Z_0-9]*$', param_name): logging.error("Invalid parameter name %s on line %d in file %s", param_name, n, param_file) exit(1) # pylint: disable=R1722 if param_name in doc: # If the parameter name is in the dictionary, # prefix the line with comment derived from the dictionary element data = doc[param_name] prefix_parts = [ f"{data['humanName']}", ] prefix_parts += data["documentation"] for key, value in data["fields"].items(): prefix_parts.append(f"{key}: {value}") for key, value in data["values"].items(): prefix_parts.append(f"{key}: {value}") doc_text = "\n# ".join(prefix_parts) # pylint: disable=C0103 if not is_first_param_in_file: new_lines.append("\n") new_lines.append(f"# {doc_text}\n{line}") documented_params += 1 else: # If the parameter name is in not the dictionary, copy the parameter line 1-to-1 new_lines.append(line) undocumented_params.append(param_name) total_params += 1 is_first_param_in_file = False if total_params == documented_params: logging.info("Read file %s with %d parameters, all got documented", param_file, total_params) else: logging.warning("Read file %s with %d parameters, but only %s of which got documented", param_file, total_params, documented_params) logging.warning("No documentation found for: %s", ", ".join(undocumented_params)) # Open the file in write mode and write the new lines with open(param_file, "w", encoding="utf-8") as file: file.writelines(new_lines) def print_read_only_params(doc): """ Print the names of read-only parameters. Args: doc (dict): A dictionary of parameter documentation. """ logging.info("ReadOnly parameters:") for param_name, param_value in doc.items(): if 'ReadOnly' in param_value['fields'] and param_value['fields']['ReadOnly']: logging.info(param_name) if __name__ == "__main__": parser = argparse.ArgumentParser(description='Fetches on-line ArduPilot parameter documentation and adds it to all *.param and *.parm files in the current directory.') choices = ['AP_Periph', 'AntennaTracker', 'ArduCopter', 'ArduPlane', 'ArduSub', 'Blimp', 'Heli', 'Rover', 'SITL'] parser.add_argument('-t', '--vehicle-type', default='ArduCopter', choices=choices, help='The type of the vehicle. Defaults to ArduCopter') parser.add_argument('-v', '--verbose', action='store_true', help='Increase output verbosity, print ReadOnly parameter list. Defaults to false') args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') else: logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s') xml_root = get_xml_data(BASE_URL + args.vehicle_type + "/", PARAM_DEFINITION_XML_FILE) doc_dict = create_doc_dict(xml_root, args.vehicle_type) update_parameter_documentation(doc_dict) if args.verbose: print_read_only_params(doc_dict)