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
13 changes: 13 additions & 0 deletions .github/workflows/update-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,15 @@ jobs:
--out /tmp/microchip-first-party-usb-pids.json
wc -l /tmp/microchip-first-party-usb-pids.json

- name: Fetch Arduino USB PID supplement (USB-VID tier-1 supplement)
id: fetch-arduino-pids
continue-on-error: true
run: |
uv run --no-project --script \
"${{ github.workspace }}/online-data-tools/fetch_arduino_usb_pids.py" \
--out /tmp/arduino-usb-pids.json
wc -l /tmp/arduino-usb-pids.json

- name: Fetch FTDI USB PID supplement (USB-VID tier-3 supplement)
id: fetch-ftdi-pids
continue-on-error: true
Expand Down Expand Up @@ -327,6 +336,9 @@ jobs:
if [ "${{ steps.fetch-microchip-first-party-pids.outcome }}" = "success" ] && [ -s /tmp/microchip-first-party-usb-pids.json ]; then
args+=(--json "microchip-first-party-usb-pids=/tmp/microchip-first-party-usb-pids.json")
fi
if [ "${{ steps.fetch-arduino-pids.outcome }}" = "success" ] && [ -s /tmp/arduino-usb-pids.json ]; then
args+=(--json "arduino-usb-pids=/tmp/arduino-usb-pids.json")
fi
if [ "${{ steps.run-dump.outcome }}" = "success" ] && [ -s /tmp/usb-ids-rs.json ]; then
args+=(--json "usb-ids-rs=/tmp/usb-ids-rs.json")
fi
Expand Down Expand Up @@ -554,6 +566,7 @@ jobs:
echo "| raspberrypi usb-pid | ${{ steps.fetch-raspberrypi-pids.outcome }} |"
echo "| nordic usb-pids | ${{ steps.fetch-nordic-pids.outcome }} |"
echo "| microchip first-party pids | ${{ steps.fetch-microchip-first-party-pids.outcome }} |"
echo "| arduino usb-pids | ${{ steps.fetch-arduino-pids.outcome }} |"
echo "| ftdi usb-pids | ${{ steps.fetch-ftdi-pids.outcome }} |"
echo "| wch usb-pids | ${{ steps.fetch-wch-pids.outcome }} |"
echo "| teensy usb-pids | ${{ steps.fetch-teensy-pids.outcome }} |"
Expand Down
12 changes: 12 additions & 0 deletions online-data-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ is committed to orphan branches:
| `fetch_raspberrypi_usb_pids.py` | `raspberrypi/usb-pid` official PID registry | merge-compatible `/tmp/raspberrypi-usb-pids.json` |
| `fetch_nordic_usb_pids.py` | Nordic nRF Connect Programmer / DFU sources | merge-compatible `/tmp/nordic-usb-pids.json` |
| `fetch_microchip_usb_pids.py` | Microchip pyedbglib/pykitinfo plus weak AVRDUDE/board-package supplements | merge-compatible `/tmp/microchip-*-usb-pids.json` |
| `fetch_arduino_usb_pids.py` | Official ArduinoCore `boards.txt` files | merge-compatible `/tmp/arduino-usb-pids.json` |
| `fetch_ftdi_usb_pids.py` | Linux `ftdi_sio_ids.h` original-FTDI PID section | merge-compatible `/tmp/ftdi-usb-pids.json` |
| `fetch_wch_usb_pids.py` | WCH CH343 Linux driver + udev rules | merge-compatible `/tmp/wch-usb-pids.json` |
| `fetch_teensy_usb_pids.py` | PJRC Teensy core headers + loader CLI | merge-compatible `/tmp/teensy-usb-pids.json` |
Expand Down Expand Up @@ -67,6 +68,16 @@ first-party data. LowPowerLab's current package maps CurrentRanger to
local FastLED board JSON currently carries `current_ranger` as `04d8:eee5`,
so that local mismatch is noted rather than used as source truth.

The Arduino supplement parses official ArduinoCore `boards.txt` files from
Arduino-maintained AVR, SAM, SAMD, megaAVR, mbed, Renesas, and Zephyr cores.
Arduino does not publish a single public PID allocation registry; the core
board definitions are the authoritative public table for Arduino VID `0x2341`
and historical Arduino VID `0x2a03`. The workflow orders this source before
generic USB-ID feeds so current Arduino product names win. As with other
board-package data, a row from an ArduinoCore source improves VID/PID
resolution but does not prove the board exists under
`crates/fbuild-config/assets/boards`.

The FTDI supplement parses the upstream Linux `ftdi_sio_ids.h` header but only
the original FTDI PID section before the third-party marker. It emits a small
allowlist of missing FTDI-owned bridge/product rows such as `0403:6040` and
Expand Down Expand Up @@ -135,6 +146,7 @@ uv run --no-project --with pytest pytest online-data-tools/test_espressif_usb_pi
uv run --no-project --with pytest pytest online-data-tools/test_raspberrypi_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_nordic_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_microchip_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_arduino_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_ftdi_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_wch_usb_pids.py -v
uv run --no-project --with pytest pytest online-data-tools/test_teensy_usb_pids.py -v
Expand Down
197 changes: 197 additions & 0 deletions online-data-tools/fetch_arduino_usb_pids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env -S uv run --no-project --script
# /// script
# requires-python = ">=3.10"
# ///
"""Fetch official Arduino USB PID rows into merge_sources.py JSON.

Arduino does not publish a single public PID registry. Its official cores do
publish Arduino-style `boards.txt` files with `vid.N` / `pid.N` declarations.
This script parses the direct GitHub `boards.txt` files for the official cores
that currently declare Arduino VID rows.

Output schema:
{
"2341:0043": {
"vendor": "Arduino SA",
"product": "Arduino Uno"
}
}
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.request
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable

ARDUINO_VENDORS = {
"2341": "Arduino SA",
"2a03": "Arduino LLC",
}


@dataclass(frozen=True)
class BoardSource:
name: str
url: str


OFFICIAL_BOARD_SOURCES = (
BoardSource(
"ArduinoCore-avr",
"https://raw.githubusercontent.com/arduino/ArduinoCore-avr/master/boards.txt",
),
BoardSource(
"ArduinoCore-sam",
"https://raw.githubusercontent.com/arduino/ArduinoCore-sam/master/boards.txt",
),
BoardSource(
"ArduinoCore-samd",
"https://raw.githubusercontent.com/arduino/ArduinoCore-samd/master/boards.txt",
),
BoardSource(
"ArduinoCore-megaavr",
"https://raw.githubusercontent.com/arduino/ArduinoCore-megaavr/master/boards.txt",
),
BoardSource(
"ArduinoCore-mbed",
"https://raw.githubusercontent.com/arduino/ArduinoCore-mbed/main/boards.txt",
),
BoardSource(
"ArduinoCore-renesas",
"https://raw.githubusercontent.com/arduino/ArduinoCore-renesas/main/boards.txt",
),
BoardSource(
"ArduinoCore-zephyr",
"https://raw.githubusercontent.com/arduino/ArduinoCore-zephyr/main/boards.txt",
),
)

_BOARD_NAME_RE = re.compile(r"^(?P<board>[A-Za-z0-9_]+)\.name=(?P<name>.+)$")
_BOARD_VID_RE = re.compile(
r"^(?P<board>[A-Za-z0-9_]+)\.(?:vid\.(?P<index_a>\d+)|"
r"upload_port\.(?P<index_b>\d+)\.vid)="
r"0x(?P<vid>[0-9A-Fa-f]{4})$"
)
_BOARD_PID_RE = re.compile(
r"^(?P<board>[A-Za-z0-9_]+)\.(?:pid\.(?P<index_a>\d+)|"
r"upload_port\.(?P<index_b>\d+)\.pid)="
r"0x(?P<pid>[0-9A-Fa-f]{4})$"
)


def _fetch(url: str, *, timeout: float = 30.0) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "fbuild-bot/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")


def _normalize_product_name(name: str) -> str:
return re.sub(r"\s+", " ", name).strip()


def _collapse_board_products(names: list[str]) -> str:
unique = sorted(set(_normalize_product_name(name) for name in names))
if len(unique) == 1:
return unique[0]
return " / ".join(unique)


def _merge_fill_gaps(
base: dict[str, dict[str, str]],
supplement: dict[str, dict[str, str]],
) -> dict[str, dict[str, str]]:
out = dict(base)
for key, value in sorted(supplement.items()):
out.setdefault(key, value)
return out


def parse_boards_txt(text: str) -> dict[str, dict[str, str]]:
"""Parse Arduino-style VID/PID declarations from `boards.txt`."""
board_names: dict[str, str] = {}
vids: dict[tuple[str, str], str] = {}
pids: dict[tuple[str, str], str] = {}

for raw_line in text.splitlines():
line = raw_line.strip()
name_match = _BOARD_NAME_RE.match(line)
if name_match:
board_names[name_match.group("board")] = name_match.group("name").strip()
continue
vid_match = _BOARD_VID_RE.match(line)
if vid_match:
key = (
vid_match.group("board"),
vid_match.group("index_a") or vid_match.group("index_b"),
)
vids[key] = vid_match.group("vid").lower()
continue
pid_match = _BOARD_PID_RE.match(line)
if pid_match:
key = (
pid_match.group("board"),
pid_match.group("index_a") or pid_match.group("index_b"),
)
pids[key] = pid_match.group("pid").lower()

names_by_vidpid: dict[str, list[str]] = {}
for key, vid in vids.items():
if vid not in ARDUINO_VENDORS or key not in pids:
continue
board_name = board_names.get(key[0])
if board_name is None:
continue
names_by_vidpid.setdefault(f"{vid}:{pids[key]}", []).append(board_name)

return {
vidpid: {
"vendor": ARDUINO_VENDORS[vidpid.split(":", 1)[0]],
"product": _collapse_board_products(names),
}
for vidpid, names in sorted(names_by_vidpid.items())
}


def collect(
*,
fetch: Callable[[str], str] = _fetch,
sources: Iterable[BoardSource] = OFFICIAL_BOARD_SOURCES,
) -> dict[str, dict[str, str]]:
entries: dict[str, dict[str, str]] = {}
for source in sources:
try:
text = fetch(source.url)
except Exception as e:
print(f"warning: {source.name}: {source.url}: fetch failed: {e}", file=sys.stderr)
continue

rows = parse_boards_txt(text)
entries = _merge_fill_gaps(entries, rows)
print(f"{source.name}: {len(rows)} PID(s)", file=sys.stderr)
return dict(sorted(entries.items()))


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args()

entries = collect()
args.out.write_text(
json.dumps(OrderedDict(sorted(entries.items())), indent=2, ensure_ascii=False)
+ "\n",
encoding="utf-8",
)
print(f"wrote {args.out}: {len(entries)} Arduino PID(s)")
return 0


if __name__ == "__main__":
sys.exit(main())
114 changes: 114 additions & 0 deletions online-data-tools/test_arduino_usb_pids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env -S uv run --no-project --with pytest --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest"]
# ///
"""Tests for fetch_arduino_usb_pids.py."""

from __future__ import annotations

import json
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import fetch_arduino_usb_pids # noqa: E402


BOARDS_FIXTURE = """\
uno.name=Arduino Uno
uno.vid.0=0x2341
uno.pid.0=0x0043
uno.vid.1=0x2A03
uno.pid.1=0x0043

leonardo.name=Arduino Leonardo
leonardo.vid.0=0x2341
leonardo.pid.0=0x8036

mkrwifi1010.name=Arduino MKR WiFi 1010
mkrwifi1010.vid.0=0x2341
mkrwifi1010.pid.0=0x8057

zero_edbg.name=Arduino Zero (Programming Port)
zero_edbg.vid.0=0x03eb
zero_edbg.pid.0=0x2157
"""


def test_parse_boards_txt_extracts_arduino_vids_only() -> None:
rows = fetch_arduino_usb_pids.parse_boards_txt(BOARDS_FIXTURE)

assert rows["2341:0043"] == {
"vendor": "Arduino SA",
"product": "Arduino Uno",
}
assert rows["2a03:0043"] == {
"vendor": "Arduino LLC",
"product": "Arduino Uno",
}
assert rows["2341:8036"]["product"] == "Arduino Leonardo"
assert rows["2341:8057"]["product"] == "Arduino MKR WiFi 1010"
assert "03eb:2157" not in rows
assert list(rows) == sorted(rows)


def test_parse_boards_txt_collapses_duplicate_pid_names() -> None:
fixture = BOARDS_FIXTURE + """\
uno_clone.name=Arduino Uno Compatible
uno_clone.vid.0=0x2341
uno_clone.pid.0=0x0043
"""
rows = fetch_arduino_usb_pids.parse_boards_txt(fixture)

assert rows["2341:0043"]["product"] == "Arduino Uno / Arduino Uno Compatible"


def test_collect_uses_first_official_source_on_conflict() -> None:
first = fetch_arduino_usb_pids.BoardSource("first", "first")
second = fetch_arduino_usb_pids.BoardSource("second", "second")

def fake_fetch(url: str) -> str:
if url == "first":
return BOARDS_FIXTURE
return """\
uno.name=Different Uno Name
uno.vid.0=0x2341
uno.pid.0=0x0043
nano_matter.name=Arduino Nano Matter
nano_matter.vid.0=0x2341
nano_matter.pid.0=0x0072
"""

rows = fetch_arduino_usb_pids.collect(
fetch=fake_fetch,
sources=(first, second),
)

assert rows["2341:0043"]["product"] == "Arduino Uno"
assert rows["2341:0072"]["product"] == "Arduino Nano Matter"


def test_main_emits_merge_sources_shape(tmp_path: Path) -> None:
out = tmp_path / "arduino.json"
old_collect = fetch_arduino_usb_pids.collect
try:
fetch_arduino_usb_pids.collect = lambda: {
"2341:0043": {
"vendor": "Arduino SA",
"product": "Arduino Uno",
}
}
sys.argv = ["fetch_arduino_usb_pids.py", "--out", str(out)]
assert fetch_arduino_usb_pids.main() == 0
finally:
fetch_arduino_usb_pids.collect = old_collect

data = json.loads(out.read_text(encoding="utf-8"))
assert data == {
"2341:0043": {
"vendor": "Arduino SA",
"product": "Arduino Uno",
}
}
Loading