"""Check parameter files against ArduPlane's own parameter metadata. Usage: uv run --no-project python tools/check_params.py params/*.parm Build the metadata in a private ArduPilot copy (never the SITL tree): Tools/autotest/param_metadata/param_parse.py --vehicle ArduPlane --format json Reports unknown names, values outside a documented range, and values not in a documented list. Exits non-zero on any finding. Second mode, after loading the aircraft: compare what the board holds with the files. uv run --no-project python tools/check_params.py --loaded params/terrahawk-common.parm \ params/board-luxf765.parm params/airframe.parm Save the board's parameters first (Mission Planner: Full Parameter List, Save to file). Give the files in load order; a later file wins for a name that is in two files. Reports every name the board does not have (load again after a reboot) and every value that differs. """ import json import re import sys from pathlib import Path def read_params(path): """name -> (value text, line) from a .parm file or a saved board dump (NAME,VALUE or NAME VALUE).""" out = {} for n, raw in enumerate(Path(path).read_text().splitlines(), 1): line = raw.split("#", 1)[0].strip() if not line: continue parts = re.split(r"[,\s]+", line) if len(parts) >= 2: out[parts[0]] = (parts[1], n) return out if len(sys.argv) > 2 and sys.argv[1] == "--loaded": board = read_params(sys.argv[2]) want = {} for path in sys.argv[3:]: for name, (value, n) in read_params(path).items(): want[name] = (value, f"{path}:{n}") missing = differ = 0 for name, (value, where) in want.items(): if name not in board: print(f"{where}: {name} not on the board (reboot, then load the files again)") missing += 1 continue a, b = float(value), float(board[name][0]) # the board stores float32, so 0.347 reads back as 0.34700000286 if abs(a - b) > max(1e-6, 1e-5 * abs(a)): print(f"{where}: {name} is {board[name][0]} on the board, {value} in the file") differ += 1 print(f"missing: {missing} different: {differ} checked: {len(want)}") sys.exit(1 if missing or differ else 0) meta = json.loads(Path(sys.argv[1]).read_text()) known = {} for group in meta.values(): for name, info in group.items(): known[name] = info def script_params(): """Names our Lua scripts add (sim/scripts/*.lua): their PREFIX plus each name they add. ArduPlane's metadata cannot know them, so a .parm row for one is checked against the script that declares it.""" names = {} for lua in sorted((Path(__file__).resolve().parent.parent / "sim" / "scripts").glob("*.lua")): text = lua.read_text() m = re.search(r"PREFIX\s*=\s*(?:\d+\s*,\s*)?['\"]([A-Z0-9_]+)['\"]", text) if not m: continue found = re.findall(r"\b(?:P|add)\(\s*\d+\s*,\s*['\"]([A-Z0-9_]+)['\"]", text) # P(i, 'NAME', d), add(i, "NAME", d) t = re.search(r"local names\s*=\s*\{(.*?)\}", text, re.S) # local names = { 'A', 'B' } if t: found += re.findall(r"['\"]([A-Z0-9_]+)['\"]", t.group(1)) for suffix in found: names[m.group(1) + suffix] = lua.name return names SCRIPT_PARAMS = script_params() def expand(name): """Metadata uses templates like SERVOx_ or CAN_Dx_; try direct first, then digit-to-x. A script's own parameter has no range or list to check.""" if name in known: return known[name] if name in SCRIPT_PARAMS: return {} for pattern in (re.sub(r"(?<=[A-Z_])\d+(?=_)", "x", name, count=1), re.sub(r"\d+(?=_)", "x", name), re.sub(r"\d", "x", name)): if pattern in known: return known[pattern] return None bad = 0 for path in sys.argv[2:]: for n, line in enumerate(Path(path).read_text().splitlines(), 1): range_ok = "range-ok" in line value_ok = "value-ok" in line # a value the metadata's list omits on purpose (e.g. a board's own GPIO) line = line.split("#", 1)[0].strip() if not line: continue name, value = line.split()[:2] info = expand(name) if info is None: print(f"{path}:{n}: unknown parameter {name}") bad += 1 continue v = float(value) rng = info.get("Range") if rng and not range_ok and not (float(rng["low"]) <= v <= float(rng["high"])): print(f"{path}:{n}: {name} {value} outside {rng['low']}..{rng['high']}") bad += 1 vals = info.get("Values") if vals and not value_ok and not info.get("Bitmask") and str(int(v)) not in vals and value not in vals: print(f"{path}:{n}: {name} {value} not a listed value") bad += 1 # The airframe file sets elevon endpoints by output number, and the number belongs to the board: check them against # the first-flight board named in sim/params_sync.py. On the NxtPX4v2 an old SERVO7/8 row would trim the pusher and # the camera trigger, and leave the elevons on defaults (2026-10-04). repo = Path(__file__).resolve().parent.parent m = re.search(r'^FIRST_FLIGHT_BOARD\s*=\s*"([^"]+)"', (repo / "sim" / "params_sync.py").read_text(), re.M) board_path = repo / "params" / m.group(1) if m else None if board_path and board_path.exists(): funcs = {int(k[5:-9]): float(v) for k, (v, _) in read_params(board_path).items() if re.fullmatch(r"SERVO\d+_FUNCTION", k)} for path in sys.argv[2:]: if not Path(path).name.startswith("airframe"): continue for name, (value, n) in read_params(path).items(): mm = re.fullmatch(r"SERVO(\d+)_(MIN|MAX|TRIM|REVERSED)", name) if mm and funcs.get(int(mm.group(1))) not in (77.0, 78.0): print(f"{path}:{n}: {name}: output {mm.group(1)} is not an elevon on {board_path.name} " f"(function {funcs.get(int(mm.group(1)), 'unset')})") bad += 1 # SD-card message interval files (params/sdcard/message-intervals-chan.txt, read at boot from the card's root by # GCS_MAVLINK::initialise_message_intervals_from_config_files). N is the MAVLink channel, counted over the serial ports # whose protocol is MAVLink in port order with USB (SERIAL0, MAVLink2 by default) first, so a later SERIAL1 change # renumbers it silently (th-hangar, 2026-10-12). The parser takes " " lines only: a comment line # reads as message 0, HEARTBEAT, at interval 0. LORA_SERIAL = 2 # the telemetry radio's port on the first-flight board (board file: SERIAL2, TELEM2) if board_path and board_path.exists(): board = read_params(board_path) mav_ports = [k for k in range(10) if float(board.get(f"SERIAL{k}_PROTOCOL", ("2" if k == 0 else "-1", 0))[0]) in (1.0, 2.0)] for f in sorted((repo / "params" / "sdcard").glob("message-intervals-chan*.txt")): chan = int(re.search(r"chan(\d+)", f.name).group(1)) if LORA_SERIAL not in mav_ports or mav_ports.index(LORA_SERIAL) != chan: print(f"{f}: channel {chan}, but SERIAL{LORA_SERIAL} is MAVLink channel " f"{mav_ports.index(LORA_SERIAL) if LORA_SERIAL in mav_ports else 'none'} on {board_path.name} " f"(MAVLink ports {mav_ports})") bad += 1 for n, line in enumerate(f.read_text().splitlines(), 1): if not re.fullmatch(r"\d+ \d+", line): print(f"{f}:{n}: not ' ': {line!r}") bad += 1 # The flight scripts' arming protection (AP_Scripting::arming_checks): SCR_LD_CHECKSUM/SCR_RUN_CHECKSUM refuse arming with # a script missing, changed or dead, but the whole check returns "pass" when SCR_ENABLE is 0 or SCR_DEBUG_OPTS has bit 4 # (DISABLE_PRE_ARM) set, and the save-checksums bit 5 is easily set as 48 by mistake (th-hangar, 2026-10-12). for path in sys.argv[2:]: for name, (value, n) in read_params(path).items(): if name == "SCR_ENABLE" and float(value) != 1: print(f"{path}:{n}: SCR_ENABLE {value}: with scripting off every scripting pre-arm, the checksums included, passes") bad += 1 if name == "SCR_DEBUG_OPTS" and int(float(value)) & 16: print(f"{path}:{n}: SCR_DEBUG_OPTS {value} has bit 4 (DISABLE_PRE_ARM): the script pre-arm and checksums are off") bad += 1 # The checklist gate's list on the SD card must match the parameter files (tools/checklist_code.py): a stale list # hashes the wrong names, so every code would be refused or, worse, an edited value would not be gated. sys.path.insert(0, str(repo / "tools")) import checklist_code # noqa: E402 if checklist_code.LIST_FILE.exists() and checklist_code.read_list() != checklist_code.gated_names(): print(f"{checklist_code.LIST_FILE.relative_to(repo)}: stale against the parameter files; run checklist_code.py list") bad += 1 # The tick page's codes (th-clr-codes.json) must be the codes of the published files, or a pilot would be shown codes the # aircraft refuses. if checklist_code.CODES_FILE.exists(): import json # noqa: E402 if json.loads(checklist_code.CODES_FILE.read_text()) != checklist_code.codes_json(): print(f"{checklist_code.CODES_FILE.relative_to(repo)}: stale against the parameter files; run checklist_code.py list") bad += 1 else: print(f"{checklist_code.CODES_FILE.relative_to(repo)}: missing; run checklist_code.py list") bad += 1 # The dock's interface texts (hangar.md cf59256d): a STATUSTEXT carries 50 characters, a longer text arrives in pieces, # and the dock matches its texts (the 'gpsguard: GPS lost' prefix, 'tipguard: tipped over; disarmed', 'tipguard: armed') # in the first piece only. So every text the GPS-loss and tip-over guards send, candidates included, must fit; one built # with string.format cannot be measured here, so it is refused too. Other scripts' long texts are only noted. STATUSTEXT_MAX = 50 # Known, with the fix that removes it; delete the entry when the fix is adopted, so the check counts it again. KNOWN_LONG = {} # v2.4e's 61-character takeover text was the only entry; v2.4f's adoption removed it send_re = re.compile(r"send_text\(\s*\d+\s*,\s*(?:'([^']*)'|\"([^\"]*)\"|(string\.format))") for f in sorted((repo / "sim" / "scripts").glob("*.lua")): interface = f.name.startswith(("gps_loss_guard", "tip_guard")) for n, line in enumerate(f.read_text().splitlines(), 1): if line.lstrip().startswith("--"): continue for m in send_re.finditer(line): text = m.group(1) if m.group(1) is not None else m.group(2) where = f"{f.relative_to(repo)}:{n}" if text is None: if interface: print(f"{where}: a dock interface text built with string.format; its length cannot be checked") bad += 1 elif len(text) > STATUSTEXT_MAX: known = KNOWN_LONG.get((f.name, text)) if interface and known: print(f"known: {where}: {len(text)} characters ({known}): {text!r}") elif interface: print(f"{where}: dock interface text is {len(text)} characters, over STATUSTEXT's {STATUSTEXT_MAX}: {text!r}") bad += 1 else: print(f"note: {where}: {len(text)} characters arrives in pieces: {text!r}") print("findings:", bad) sys.exit(1 if bad else 0)