diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4793d87 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# The registry and the validator that guards it are maintainer-owned. +# `legacy:` in particular is maintainer-set; see LEGACY_PIDS in validate.py. +/usb-ids.yaml @mrpollo +/validate.py @mrpollo diff --git a/.github/ISSUE_TEMPLATE/pid-request.yml b/.github/ISSUE_TEMPLATE/pid-request.yml index d5dcef4..42b0eac 100644 --- a/.github/ISSUE_TEMPLATE/pid-request.yml +++ b/.github/ISSUE_TEMPLATE/pid-request.yml @@ -25,7 +25,9 @@ body: id: boards attributes: label: Board(s) the PIDs are for - description: One PID is assigned per board. + description: >- + PIDs are assigned from your manufacturer's block of 16 (your first + request claims one), one PID per board. validations: required: true - type: checkboxes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 804b531..39667c9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,6 @@ **Board(s):** -- [ ] Entries added to `usb-ids.yaml` (one per PID, lowest free PIDs) +- [ ] 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 - [ ] We are a Dronecode Foundation member (or state your affiliation below) diff --git a/README.md b/README.md index e2cf073..92be6cf 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ 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. -There are no reservations: PIDs are assigned to real boards. Request one -when you have hardware to name. +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 +it. Claim a block when you have hardware to name, not in advance; when a +block fills up, claim another. ## Requesting a PID @@ -21,6 +23,7 @@ when you have hardware to name. - name: Acme Robotics px4_vendor: acme # your directory under boards/ in PX4-Autopilot contact: usb@acme.example + blocks: ["0x0070"] # 16 PIDs, 0x0070-0x007F pids: - pid: "0x0070" board: Acme FC1 @@ -31,8 +34,10 @@ when you have hardware to name. Dronecode Foundation membership and merges. Assignments are at maintainer discretion. -Pick the lowest free PID; one entry per PID. If you can't open a PR, use -the [PID request issue form](../../issues/new/choose). +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 +PR, use the [PID request issue form](../../issues/new/choose). ## Field reference @@ -43,6 +48,8 @@ the [PID request issue form](../../issues/new/choose). | `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. | +| `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. | ## Validation diff --git a/usb-ids.yaml b/usb-ids.yaml index 1f5b5ad..9578c72 100644 --- a/usb-ids.yaml +++ b/usb-ids.yaml @@ -3,6 +3,11 @@ # Single source of truth for PID assignments under VID 0x3643. # See README.md for the assignment process. # +# PIDs are assigned in blocks of 16: each manufacturer claims an +# aligned block (0xNNN0-0xNNNF), listed in `blocks`, and assigns +# PIDs from inside it. Values are hexadecimal: after "0x0039" +# comes "0x003A", not "0x0040". +# # PID format: "0x" + 4 uppercase hex digits, quoted (YAML would otherwise # parse some values as integers). @@ -13,6 +18,7 @@ manufacturers: - name: PX4/Dronecode px4_vendor: px4 contact: rroche@linuxfoundation.org + blocks: ["0x0010"] # 0x0010-0x001F pids: - pid: "0x001D" board: FMU-v6XRT @@ -21,6 +27,7 @@ manufacturers: - name: ZeroOne px4_vendor: zeroone contact: menghua@01aero.com + blocks: ["0x15E0"] # 0x15E0-0x15EF pids: - pid: "0x15E0" board: X6 @@ -30,6 +37,7 @@ manufacturers: # Not upstream in PX4 yet; slug matches boards/newbeedrone/ from PR #26966. px4_vendor: newbeedrone contact: kelvin@newbeedrone.com + blocks: ["0x0050"] # 0x0050-0x005F pids: - pid: "0x0050" board: PixNova @@ -42,6 +50,7 @@ manufacturers: - pid: "0x0001" board: Syro V6X date: 2026-08-03 + legacy: true - name: Agam Robotics px4_vendor: agam-robotics @@ -50,6 +59,8 @@ manufacturers: - pid: "0x0002" board: Agam Autopilot v6X-RT date: 2026-08-18 + legacy: true - pid: "0x0003" board: Agam MegH7 date: 2026-08-22 + legacy: true diff --git a/validate.py b/validate.py index 79fd21a..5652292 100644 --- a/validate.py +++ b/validate.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Validate usb-ids.yaml, the Dronecode USB ID registry. -Checks structure, field formats, PID uniqueness (case-insensitive), and -px4_vendor slug uniqueness. Prints one error per line and exits 1 on any +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. Only dependency: PyYAML. @@ -15,14 +17,21 @@ PID_RE = re.compile(r"^0x[0-9A-F]{4}$") 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-]*$") DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") TOP_KEYS = {"vid", "vendor_string", "manufacturers"} MFR_REQUIRED = {"name", "contact", "pids"} -MFR_OPTIONAL = {"px4_vendor"} -PID_KEYS = {"pid", "board", "date"} +MFR_OPTIONAL = {"px4_vendor", "blocks"} +PID_REQUIRED = {"pid", "board", "date"} +PID_OPTIONAL = {"legacy"} + +# Assignments predating the block policy. Maintainer-set: extending this set +# is a deliberate edit here, not something a PID request can grant itself. +LEGACY_PIDS = {"0x0001", "0x0002", "0x0003"} +LEGACY_CUTOFF = "2026-09-01" def validate(doc): @@ -58,6 +67,8 @@ def err(msg): seen_pids = {} # normalized pid -> manufacturer name seen_vendors = {} # px4_vendor -> manufacturer name + seen_blocks = {} # block start -> manufacturer name + assigned = [] # (pid as int, pid as written, manufacturer name) for i, mfr in enumerate(manufacturers): where = f"manufacturers[{i}]" @@ -96,6 +107,34 @@ def err(msg): else: seen_vendors[px4_vendor] = name + # Blocks are all 16 wide and 16-aligned, so two of them overlap only + # if they share a start value: unique starts means no overlap. + blocks = mfr.get("blocks") + mfr_blocks = set() + if "blocks" in mfr: + if not isinstance(blocks, list) or not blocks: + err(f"{where}: 'blocks' must be a non-empty list") + else: + for block in blocks: + if not isinstance(block, str) or not BLOCK_RE.match(block): + err( + f"{where}: block '{block}' is not a quoted 0xXXX0 " + "uppercase hex string; a block start must end in 0 " + "(16-aligned)" + ) + continue + if block in mfr_blocks: + err(f"{where}: block '{block}' listed twice") + continue + mfr_blocks.add(block) + if block in seen_blocks: + err( + f"{where}: block '{block}' already claimed by " + f"'{seen_blocks[block]}'" + ) + else: + seen_blocks[block] = name + pids = mfr.get("pids") if "pids" not in mfr: continue @@ -109,12 +148,13 @@ def err(msg): err(f"{pwhere}: expected a mapping") continue - for key in sorted(set(entry) - PID_KEYS): + for key in sorted(set(entry) - PID_REQUIRED - PID_OPTIONAL): err(f"{pwhere}: unknown field '{key}'") - for key in sorted(PID_KEYS - set(entry)): + for key in sorted(PID_REQUIRED - set(entry)): err(f"{pwhere}: missing field '{key}'") pid = entry.get("pid") + pid_ok = False if "pid" in entry: if not isinstance(pid, str) or not PID_RE.match(pid): err( @@ -122,6 +162,7 @@ def err(msg): "uppercase hex string" ) else: + pid_ok = True pwhere = f"{where} pid {pid}" norm = pid.lower() if norm in seen_pids: @@ -131,17 +172,54 @@ def err(msg): ) else: seen_pids[norm] = name + assigned.append((int(pid, 16), pid, name)) board = entry.get("board") if "board" in entry and (not isinstance(board, str) or not board.strip()): err(f"{pwhere}: 'board' must be a non-empty string") date = entry.get("date") - if "date" in entry: - # PyYAML may parse unquoted dates as datetime.date - date_str = date.isoformat() if hasattr(date, "isoformat") else date - if not isinstance(date_str, str) or not DATE_RE.match(date_str): - err(f"{pwhere}: date '{date}' must be YYYY-MM-DD") + # PyYAML may parse unquoted dates as datetime.date + date_str = date.isoformat() if hasattr(date, "isoformat") else date + date_ok = isinstance(date_str, str) and bool(DATE_RE.match(date_str)) + if "date" in entry and not date_ok: + err(f"{pwhere}: date '{date}' must be YYYY-MM-DD") + + legacy = entry.get("legacy") + if "legacy" in entry: + if legacy is not True: + err(f"{pwhere}: 'legacy' must be true when present") + elif not (pid_ok and pid in LEGACY_PIDS): + err( + f"{pwhere}: 'legacy' is maintainer-set only; this pid " + "is not in the frozen legacy set" + ) + elif date_ok and date_str >= LEGACY_CUTOFF: + err( + f"{pwhere}: 'legacy' is only for assignments predating " + f"the block policy (date before {LEGACY_CUTOFF})" + ) + + # 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}" + if start not in mfr_blocks: + err( + f"{pwhere}: outside {name}'s claimed blocks; claim " + f"block '{start}' in 'blocks' or move the pid into a " + "claimed block" + ) + + # A claimed block must not hold another manufacturer's pid, legacy ones + # included; this is what keeps the pre-policy 0x0000-0x000F range frozen. + for pid_int, pid, owner in assigned: + start = f"0x{pid_int & ~0xF:04X}" + holder = seen_blocks.get(start) + if holder is not None and holder != owner: + err( + f"manufacturer '{holder}': block '{start}' contains pid " + f"{pid} assigned to '{owner}'" + ) return errors