"""Checklist codes for th_prearm.lua's hard gate (th-avionics, 2026-10-13; docs/agent-threads/checklists/). The one definition the aircraft's script, the test harness and the tick page share. A code says "this checklist was cleared against these parameter values": when a gated value changes, the code no longer matches and arming is refused. It is an interlock, not security; anyone with the list can compute a code. The gated names: every parameter in the base files the first flight loads (the common file, the first-flight board named in sim/params_sync.py, the airframe file), except any name an overlay file sets (params/payload-*.parm, pack-*.parm, test-*.parm). Overlay values vary per flight by design (the stall test's Q_ASSIST_SPEED 14, the TFmini's RNGFND1_TYPE), and the card that loads an overlay governs them. Also out: the names the aircraft rewrites itself (CALIBRATED), which would make a code stale on their own. Names sorted; the list goes to the SD card. The hash: FNV-1a 32-bit over each gated value as little-endian float32 bytes, in list order. ArduPilot stores parameters as float32, so these are the bytes the aircraft holds, however a value is printed. The code: the low 24 bits (6 hex digits) of FNV-1a 32-bit over the checklist id's ASCII bytes, then the hash's 4 little-endian bytes. 24 bits because TH_CLR_n is a parameter, and a float32 holds integers exactly only to 2^24. th_prearm.lua computes the same from the values it reads. uv run --no-project python tools/checklist_code.py list # writes th-clr-list.txt and th-clr-codes.json uv run --no-project python tools/checklist_code.py codes # the gates' codes for the files uv run --no-project python tools/checklist_code.py codes --values saved.param # for a board dump From Python (the harness): checklist_code.codes(values) with values a dict of name to number read from the aircraft. """ import re import struct import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent PARAMS = REPO / "params" LIST_FILE = PARAMS / "sdcard" / "th-clr-list.txt" # the codes for the published files, for the tick page (th-docs-site fcf2bf69); a board whose gated values differ from the # files gets other codes, so a published code can only arm a board that matches the files (it fails closed otherwise) CODES_FILE = PARAMS / "sdcard" / "th-clr-codes.json" OVERLAY_GLOBS = ("payload-*.parm", "pack-*.parm", "test-*.parm") # rewritten on the aircraft, so the board differs from the file by design: AHRS_TRIM_X/Y by the level calibration # (airframe.parm holds 0), Q_M_THST_HOVER learned and saved in flight (Q_M_HOVER_LEARN 2). COMPASS_MOT_X/Y/Z stay # gated: tools/fit_compass_mot.py writes the fitted values into airframe.parm, so file and board agree. CALIBRATED = {"AHRS_TRIM_X", "AHRS_TRIM_Y", "Q_M_THST_HOVER"} # TH_CLR_n carries the code of gate n; the ids are the checklist definitions' ids (checklists/.yaml) GATES = {1: "before-power-up", 2: "bench-checks", 3: "failsafe-checks", 4: "night-build"} FNV_OFFSET, FNV_PRIME = 2166136261, 16777619 def read_parm(path): """name -> value text from a .parm file or a saved board dump; a later row wins. Rows are NAME,VALUE or NAME VALUE (our files, Mission Planner, MAVProxy) or QGroundControl's VEHICLE COMPONENT NAME VALUE TYPE.""" out = {} for raw in Path(path).read_text().splitlines(): line = raw.split("#", 1)[0].strip() parts = re.split(r"[,\s]+", line) if line else [] if len(parts) >= 4 and parts[0].isdigit() and parts[1].isdigit(): out[parts[2]] = parts[3] elif len(parts) >= 2: out[parts[0]] = parts[1] return out def base_files(): m = re.search(r'^FIRST_FLIGHT_BOARD\s*=\s*"([^"]+)"', (REPO / "sim" / "params_sync.py").read_text(), re.M) if not m: raise SystemExit("sim/params_sync.py names no FIRST_FLIGHT_BOARD") return [PARAMS / "terrahawk-common.parm", PARAMS / m.group(1), PARAMS / "airframe.parm"] def gated_names(): base = {} for f in base_files(): base.update(read_parm(f)) overlay = set() for pattern in OVERLAY_GLOBS: for f in PARAMS.glob(pattern): overlay |= set(read_parm(f)) return sorted(set(base) - overlay - CALIBRATED) def file_values(): values = {} for f in base_files(): values.update({k: float(v) for k, v in read_parm(f).items()}) return values def fnv1a(data, h=FNV_OFFSET): for b in data: h = ((h ^ b) * FNV_PRIME) & 0xFFFFFFFF return h def params_hash(values, names): missing = [n for n in names if n not in values] if missing: raise KeyError(f"no value for {len(missing)} gated parameter(s), first {missing[0]}") h = FNV_OFFSET for n in names: h = fnv1a(struct.pack("= 3 and argv[1] == "--values": values = {k: float(v) for k, v in read_parm(argv[2]).items()} missing = [n for n in names if n not in values] if missing: # a code from a partial dump could never match the aircraft, so give none raise SystemExit(f"{argv[2] if len(argv) >= 3 else 'the files'}: no value for {len(missing)} of {len(names)} " f"gated parameters (first {missing[0]}); not a full parameter download, or a format " "this tool does not read") for n, c in codes(values, names).items(): print(f"TH_CLR_{n} {int(c, 16)} # {c}, {GATES[n]}") if __name__ == "__main__": main(sys.argv[1:])