Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .github/ISSUE_TEMPLATE/pid-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions usb-ids.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -21,6 +27,7 @@ manufacturers:
- name: ZeroOne
px4_vendor: zeroone
contact: menghua@01aero.com
blocks: ["0x15E0"] # 0x15E0-0x15EF
pids:
- pid: "0x15E0"
board: X6
Expand All @@ -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
Expand All @@ -42,6 +50,7 @@ manufacturers:
- pid: "0x0001"
board: Syro V6X
date: 2026-08-03
legacy: true

- name: Agam Robotics
px4_vendor: agam-robotics
Expand All @@ -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
100 changes: 89 additions & 11 deletions validate.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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}]"
Expand Down Expand Up @@ -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
Expand All @@ -109,19 +148,21 @@ 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(
f"{pwhere}: pid '{pid}' is not a quoted 0xXXXX "
"uppercase hex string"
)
else:
pid_ok = True
pwhere = f"{where} pid {pid}"
norm = pid.lower()
if norm in seen_pids:
Expand All @@ -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

Expand Down
Loading