diff --git a/.github/ISSUE_TEMPLATE/pid-request.yml b/.github/ISSUE_TEMPLATE/pid-request.yml index 42b0eac..c66367e 100644 --- a/.github/ISSUE_TEMPLATE/pid-request.yml +++ b/.github/ISSUE_TEMPLATE/pid-request.yml @@ -30,6 +30,14 @@ body: request claims one), one PID per board. validations: required: true + - type: input + id: px4_board + attributes: + label: PX4 board directory + description: >- + The `boards//` directory in PX4-Autopilot that will + use the PID, if you have one (e.g. `boards/acme/fc1`). Leave empty + if the board is not headed upstream yet. - type: checkboxes id: membership attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 39667c9..a0bc974 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,4 +6,5 @@ - [ ] All PIDs are inside our claimed block(s) in `blocks` (first request: claim a free aligned 16-PID block, `0xNNN0`) - [ ] Contact email is valid and monitored +- [ ] `px4_board` is set for every board we are upstreaming to PX4-Autopilot (merge this before opening the PX4 board PR) - [ ] We are a Dronecode Foundation member (or state your affiliation below) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bacdfa7..2273a92 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,6 +4,9 @@ on: push: branches: [main] pull_request: + schedule: + - cron: "0 6 * * 1" # weekly, catches drift on the PX4 side + workflow_dispatch: jobs: validate: @@ -15,3 +18,15 @@ jobs: python-version: "3.x" - run: pip install pyyaml - run: python3 validate.py usb-ids.yaml + + px4: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install pyyaml + - run: git clone --depth 1 --filter=blob:none --sparse https://github.com/PX4/PX4-Autopilot.git px4 + - run: git -C px4 sparse-checkout set boards + - run: python3 check_px4.py px4 usb-ids.yaml diff --git a/README.md b/README.md index 92be6cf..03bb22f 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Pixhawk/PX4-compatible hardware. This repository is the single source of truth for those assignments: [`usb-ids.yaml`](usb-ids.yaml). PX4-Autopilot CI checks board definitions against this registry, so a board -using VID `0x3643` cannot merge upstream with an unregistered PID or a PID -belonging to another manufacturer. +using VID `0x3643` cannot merge upstream unless its PID is registered here +and mapped to that board's directory with `px4_board`. PIDs are assigned in blocks of 16: your first request claims an aligned block (`0xNNN0`-`0xNNNF`) and every PID you are assigned comes from inside @@ -27,6 +27,7 @@ block fills up, claim another. pids: - pid: "0x0070" board: Acme FC1 + px4_board: acme/fc1 # boards/acme/fc1 in PX4-Autopilot date: 2026-07-10 ``` @@ -34,6 +35,12 @@ block fills up, claim another. Dronecode Foundation membership and merges. Assignments are at maintainer discretion. +3. Once your entry is merged here, open your board pull request in + PX4-Autopilot. Its USB ID check reads this registry and fails until your + PID is mapped to your board's directory with `px4_board`; if you opened + the PX4 pull request first, re-run the check after the registry pull + request merges, no changes needed. + Pick the lowest free block unless you have a reason not to; any free aligned block is fine. One entry per PID. PID values are hexadecimal: after `"0x0039"` comes `"0x003A"`, not `"0x0040"`. If you can't open a @@ -47,7 +54,8 @@ PR, use the [PID request issue form](../../issues/new/choose). | `board` | Board name | | `date` | Assignment date, `YYYY-MM-DD` | | `contact` | Email address for the manufacturer | -| `px4_vendor` | Your vendor directory name in the PX4 `boards/` tree. Optional until you upstream a board; **required before your first PX4-Autopilot board PR**, otherwise PX4 CI will reject it. | +| `px4_vendor` | Your vendor directory name in the PX4 `boards/` tree. Optional until you upstream a board; needed for `px4_board`, which must sit inside it. | +| `px4_board` | On a PID: the `/` directory under PX4's `boards/` that uses it (e.g. `siyi/n7`). **Required, and merged here, before you open the PX4-Autopilot board PR**: PX4 CI rejects a board using VID `0x3643` whose PID is not mapped to its directory. Leave it off for boards not headed upstream. | | `blocks` | List of claimed block starts, `"0x"` + 4 uppercase hex digits ending in `0`; each covers 16 PIDs (`0xNNN0`-`0xNNNF`), globally unique. Required before any non-legacy PID can be assigned. | | `legacy` | `true` on assignments that predate the block policy (before 2026-09). Maintainer-set, not for new requests. | @@ -60,6 +68,19 @@ python3 validate.py usb-ids.yaml Runs automatically on every PR and push to `main`. +`check_px4.py` checks the registry against a PX4-Autopilot checkout in both +directions: every defconfig under a `px4_board` directory that sets +`CONFIG_CDCACM_VENDORID` to `0x3643` must set `CONFIG_CDCACM_PRODUCTID` to +that entry's PID, and every PX4 board using VID `0x3643` must be claimed by +some `px4_board`. A `px4_board` whose directory is not in PX4 yet is only a +notice. A PX4 path with no board using VID `0x3643` fails rather than +passing with nothing checked. CI runs it against PX4 `main` on every PR, push, +and weekly. + +```sh +python3 check_px4.py path/to/PX4-Autopilot usb-ids.yaml +``` + Note for maintainers: PX4 CI (including release branches carrying older copies of `check_usb_ids.py`) fetches this file from `main` at check time, so schema changes must stay backward compatible: adding fields is fine, diff --git a/check_px4.py b/check_px4.py new file mode 100644 index 0000000..dd07f88 --- /dev/null +++ b/check_px4.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Check usb-ids.yaml against a PX4-Autopilot checkout. + +Usage: check_px4.py [usb-ids.yaml] + +A PX4 board defconfig at boards//nuttx-config/*/defconfig whose +CONFIG_CDCACM_VENDORID is the registry VID must set CONFIG_CDCACM_PRODUCTID +to that entry's PID (hex-int compares). + +Both directions are checked: every entry with px4_board must match the +defconfigs in that directory, and every PX4 defconfig using the registry VID +must belong to some entry's px4_board. A px4_board whose directory does not +exist in PX4 yet is a notice, not an error, so a registry entry can land +before its board PR. A PX4 path with no boards/ directory, or with no +defconfig using the registry VID, is an error, so an empty checkout cannot +pass. Prints one line per finding and exits 1 on any error. + +Only dependency: PyYAML. +""" + +import sys +from pathlib import Path + +from validate import load, validate + +DEFCONFIG_GLOB = "nuttx-config/*/defconfig" +KEYS = ("CONFIG_CDCACM_VENDORID", "CONFIG_CDCACM_PRODUCTID") + + +def read_defconfig(path): + """Return the CDCACM VID and PID settings of a defconfig.""" + values = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + key, sep, value = line.partition("=") + if sep and key in KEYS: + values[key] = value + return values + + +def hex_int(value): + try: + return int(value, 16) + except (TypeError, ValueError): + return None + + +def check(doc, px4_dir): + """Return (errors, notices, defconfigs checked, boards checked).""" + errors, notices = [], [] + boards_dir = px4_dir / "boards" + vid = int(doc["vid"], 16) + + # px4_board -> pid + entries = {} + for mfr in doc["manufacturers"]: + for entry in mfr["pids"]: + if "px4_board" in entry: + entries[entry["px4_board"]] = entry["pid"] + + # board -> [(defconfig path relative to PX4, settings)] for our VID only + px4_boards = {} + for path in sorted(boards_dir.glob(f"*/*/{DEFCONFIG_GLOB}")): + values = read_defconfig(path) + if hex_int(values.get("CONFIG_CDCACM_VENDORID")) != vid: + continue + board = "/".join(path.relative_to(boards_dir).parts[:2]) + rel = path.relative_to(px4_dir).as_posix() + px4_boards.setdefault(board, []).append((rel, values)) + + # PX4 ships boards on this VID, so finding none means a wrong or empty + # checkout, not a clean result. + if not px4_boards: + errors.append( + f"no defconfig under {boards_dir} sets " + f"CONFIG_CDCACM_VENDORID={doc['vid']}; not a PX4 checkout?" + ) + return errors, notices, 0, 0 + + checked_boards = 0 + for board, pid in sorted(entries.items()): + if not (boards_dir / board).is_dir(): + notices.append( + f"pid {pid}: boards/{board}/ not in PX4 yet; checked once " + "the board is upstream" + ) + continue + defconfigs = px4_boards.get(board) + if not defconfigs: + errors.append( + f"pid {pid}: no defconfig under boards/{board}/ sets " + f"CONFIG_CDCACM_VENDORID={doc['vid']}" + ) + continue + checked_boards += 1 + for rel, values in defconfigs: + product = values.get("CONFIG_CDCACM_PRODUCTID") + if hex_int(product) != int(pid, 16): + errors.append( + f"{rel}: CONFIG_CDCACM_PRODUCTID={product} but the " + f"registry assigns {pid} to {board}" + ) + + for board, defconfigs in sorted(px4_boards.items()): + if board not in entries: + for rel, _ in defconfigs: + errors.append( + f"{rel}: uses VID {doc['vid']} but no registry entry " + f"has px4_board '{board}'" + ) + + checked = sum(len(d) for d in px4_boards.values()) + return errors, notices, checked, checked_boards + + +def main(): + if len(sys.argv) not in (2, 3): + print(__doc__.splitlines()[2], file=sys.stderr) + return 2 + px4_dir = Path(sys.argv[1]) + path = sys.argv[2] if len(sys.argv) > 2 else "usb-ids.yaml" + if not (px4_dir / "boards").is_dir(): + print(f"error: {px4_dir}/boards is not a directory", file=sys.stderr) + return 1 + + ok, doc = load(path) + if not ok: + return 1 + # The cross-check trusts the registry's structure; stop if it is invalid. + errors = validate(doc) + if not errors: + errors, notices, checked, boards = check(doc, px4_dir) + for n in notices: + print(f"notice: {n}") + for e in errors: + print(f"error: {e}", file=sys.stderr) + if errors: + print(f"{path}: {len(errors)} error(s) against {px4_dir}", file=sys.stderr) + return 1 + print( + f"{path}: matches {px4_dir}, checked {checked} defconfig(s) " + f"across {boards} mapped board(s)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/usb-ids.yaml b/usb-ids.yaml index 384eb78..65df23e 100644 --- a/usb-ids.yaml +++ b/usb-ids.yaml @@ -10,6 +10,10 @@ # # PID format: "0x" + 4 uppercase hex digits, quoted (YAML would otherwise # parse some values as integers). +# +# `px4_board` on a PID names the PX4 boards// directory that +# ships it; absent means the board is not upstream yet. check_px4.py checks +# it against PX4. vid: "0x3643" vendor_string: "Dronecode Project, Inc." @@ -23,6 +27,7 @@ manufacturers: - pid: "0x001D" board: FMU-v6XRT date: 2023-11-01 + px4_board: px4/fmu-v6xrt - name: ZeroOne px4_vendor: zeroone @@ -32,6 +37,7 @@ manufacturers: - pid: "0x15E0" board: X6 date: 2024-08-01 + px4_board: zeroone/x6 - name: NewBeeDrone # Not upstream in PX4 yet; slug matches boards/newbeedrone/ from PR #26966. @@ -60,6 +66,7 @@ manufacturers: board: Agam Autopilot v6X-RT date: 2026-08-18 legacy: true + px4_board: agam/fmu-v6xrt - pid: "0x0003" board: Agam MegH7 date: 2026-08-22 @@ -73,9 +80,11 @@ manufacturers: - pid: "0x04B0" board: SIYI N7 date: 2026-09-11 + px4_board: siyi/n7 - pid: "0x04BF" board: SIYI UniFC 6 PICO date: 2026-09-11 + px4_board: siyi/unifc-6-pico - name: Droneer px4_vendor: droneer diff --git a/validate.py b/validate.py index 5652292..5413d8b 100644 --- a/validate.py +++ b/validate.py @@ -2,10 +2,11 @@ """Validate usb-ids.yaml, the Dronecode USB ID registry. Checks structure, field formats, PID uniqueness (case-insensitive), -px4_vendor slug uniqueness, and block allocation: every non-legacy PID sits -inside one of its manufacturer's claimed 16-PID blocks, and no block holds -another manufacturer's PID. Prints one error per line and exits 1 on any -violation, 0 when the registry is valid. +px4_vendor slug uniqueness, px4_board format and uniqueness, and block +allocation: every non-legacy PID sits inside one of its manufacturer's +claimed 16-PID blocks, and no block holds another manufacturer's PID. +Prints one error per line and exits 1 on any violation, 0 when the registry +is valid. check_px4.py compares the registry against a PX4 checkout. Only dependency: PyYAML. """ @@ -19,6 +20,9 @@ VID_RE = re.compile(r"^0x[0-9A-F]{4}$") BLOCK_RE = re.compile(r"^0x[0-9A-F]{3}0$") PX4_VENDOR_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +# Board directory names in PX4 include uppercase and underscores +# (radiolink/PIX6, saam/saampixv1_1). +PX4_BOARD_RE = re.compile(r"^[a-z0-9][a-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$") DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") @@ -26,7 +30,7 @@ MFR_REQUIRED = {"name", "contact", "pids"} MFR_OPTIONAL = {"px4_vendor", "blocks"} PID_REQUIRED = {"pid", "board", "date"} -PID_OPTIONAL = {"legacy"} +PID_OPTIONAL = {"legacy", "px4_board"} # Assignments predating the block policy. Maintainer-set: extending this set # is a deliberate edit here, not something a PID request can grant itself. @@ -68,6 +72,7 @@ def err(msg): seen_pids = {} # normalized pid -> manufacturer name seen_vendors = {} # px4_vendor -> manufacturer name seen_blocks = {} # block start -> manufacturer name + seen_boards = {} # px4_board -> pid assigned = [] # (pid as int, pid as written, manufacturer name) for i, mfr in enumerate(manufacturers): @@ -200,6 +205,33 @@ def err(msg): f"the block policy (date before {LEGACY_CUTOFF})" ) + px4_board = entry.get("px4_board") + if "px4_board" in entry: + if not isinstance(px4_board, str) or not PX4_BOARD_RE.match( + px4_board + ): + err( + f"{pwhere}: px4_board '{px4_board}' must be a " + "/ path under PX4's boards/" + ) + elif not isinstance(px4_vendor, str): + err( + f"{pwhere}: px4_board '{px4_board}' requires " + f"{name}'s 'px4_vendor'" + ) + elif not px4_board.startswith(px4_vendor + "/"): + err( + f"{pwhere}: px4_board '{px4_board}' is outside " + f"px4_vendor '{px4_vendor}'" + ) + elif px4_board in seen_boards: + err( + f"{pwhere}: px4_board '{px4_board}' already used by " + f"pid {seen_boards[px4_board]}" + ) + else: + seen_boards[px4_board] = pid + # Skip entries whose pid already failed the format check. if pid_ok and legacy is not True: start = f"0x{int(pid, 16) & ~0xF:04X}" @@ -224,13 +256,20 @@ def err(msg): return errors -def main(): - path = sys.argv[1] if len(sys.argv) > 1 else "usb-ids.yaml" +def load(path): + """Return (ok, parsed registry); prints the error when loading fails.""" try: with open(path, encoding="utf-8") as f: - doc = yaml.safe_load(f) + return True, yaml.safe_load(f) except (OSError, yaml.YAMLError) as e: print(f"error: cannot load {path}: {e}", file=sys.stderr) + return False, None + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "usb-ids.yaml" + ok, doc = load(path) + if not ok: return 1 errors = validate(doc)