diff --git a/.github/workflows/update-data.yml b/.github/workflows/update-data.yml index caf7b655..efc90e6d 100644 --- a/.github/workflows/update-data.yml +++ b/.github/workflows/update-data.yml @@ -245,6 +245,15 @@ jobs: --out /tmp/nxp-usb-pids.json wc -l /tmp/nxp-usb-pids.json + - name: Fetch Silicon Labs USB PID supplement (USB-VID tier-3 supplement) + id: fetch-silabs-pids + continue-on-error: true + run: | + uv run --no-project --script \ + "${{ github.workspace }}/online-data-tools/fetch_silabs_usb_pids.py" \ + --out /tmp/silabs-usb-pids.json + wc -l /tmp/silabs-usb-pids.json + - name: Dump PlatformIO board catalog → /tmp/all_boards.json id: dump-pio continue-on-error: true @@ -323,6 +332,12 @@ jobs: # stay stable while newer mfgtools ROM-loader PIDs fill in. args+=(--json "nxp-usb-pids=/tmp/nxp-usb-pids.json") fi + if [ "${{ steps.fetch-silabs-pids.outcome }}" = "success" ] && [ -s /tmp/silabs-usb-pids.json ]; then + # Silicon Labs rows are after generic sources so mature usb.ids + # names for common CP210x bridge PIDs stay stable while the + # Energy Micro / Silicon Labs debug-interface row fills in. + args+=(--json "silabs-usb-pids=/tmp/silabs-usb-pids.json") + fi if [ "${#args[@]}" -eq 0 ]; then echo "::warning::all USB-VID sources failed; preserving previously committed data" exit 1 @@ -500,6 +515,7 @@ jobs: echo "| teensy usb-pids | ${{ steps.fetch-teensy-pids.outcome }} |" echo "| stm usb-pids | ${{ steps.fetch-stm-pids.outcome }} |" echo "| nxp usb-pids | ${{ steps.fetch-nxp-pids.outcome }} |" + echo "| silabs usb-pids | ${{ steps.fetch-silabs-pids.outcome }} |" echo "| pio boards (platformio) | ${{ steps.dump-pio.outcome }} |" echo "| merge usb-vid | ${{ steps.merge-usb.outcome }} |" echo "| emit inlined supplement | ${{ steps.emit-inlined.outcome }} |" diff --git a/online-data-tools/README.md b/online-data-tools/README.md index 5d6e1baa..1e2aef2d 100644 --- a/online-data-tools/README.md +++ b/online-data-tools/README.md @@ -19,6 +19,7 @@ is committed to orphan branches: | `fetch_teensy_usb_pids.py` | PJRC Teensy core headers + loader CLI | merge-compatible `/tmp/teensy-usb-pids.json` | | `fetch_stm_usb_pids.py` | ST/OpenOCD ST-LINK sources | merge-compatible `/tmp/stm-usb-pids.json` | | `fetch_nxp_usb_pids.py` | NXP mfgtools/UUU config table | merge-compatible `/tmp/nxp-usb-pids.json` | +| `fetch_silabs_usb_pids.py` | Linux CP210x driver + SiliconLabsSoftware OpenOCD udev rule | merge-compatible `/tmp/silabs-usb-pids.json` | The merger scripts on the `online-data` orphan branch (`merge_sources.py`, `merge_pio_boards.py`, `build_manifest.py`, @@ -80,6 +81,15 @@ The NXP supplement parses NXP's mfgtools/UUU `config.cpp` table for VID `1fc9:0135` are labeled as NXP i.MX/i.MX RT serial downloader modes rather than as a specific downstream board. +The Silicon Labs supplement parses Linux's CP210x driver for Silicon +Labs-owned bridge defaults under VID `0x10c4`, then parses a first-party +SiliconLabsSoftware Arduino example udev rule for Energy Micro VID +`0x2544`, PID `0001`. Silicon Labs does not publish a public PID registry, +and its current Arduino core only declares board-package rows under Arduino +and Seeed VIDs (`2341:0072`, `2886:0062`, `2886:8062`), so this supplement +does not infer Silicon Labs board names from bridge-chip or debug-interface +IDs. + ## Tests ```bash @@ -92,6 +102,7 @@ uv run --no-project --with pytest pytest online-data-tools/test_wch_usb_pids.py uv run --no-project --with pytest pytest online-data-tools/test_teensy_usb_pids.py -v uv run --no-project --with pytest pytest online-data-tools/test_stm_usb_pids.py -v uv run --no-project --with pytest pytest online-data-tools/test_nxp_usb_pids.py -v +uv run --no-project --with pytest pytest online-data-tools/test_silabs_usb_pids.py -v ``` Each script declares its own PEP 723 dependencies and is runnable via diff --git a/online-data-tools/fetch_silabs_usb_pids.py b/online-data-tools/fetch_silabs_usb_pids.py new file mode 100644 index 00000000..8f5b60ff --- /dev/null +++ b/online-data-tools/fetch_silabs_usb_pids.py @@ -0,0 +1,192 @@ +#!/usr/bin/env -S uv run --no-project --script +# /// script +# requires-python = ">=3.10" +# /// +"""Fetch Silicon Labs / Energy Micro USB PID rows into merge_sources.py JSON. + +Silicon Labs does not publish a Raspberry-Pi-style PID allocation registry. +This supplement uses primary source-backed rows only: + +* Linux's CP210x driver proves current Silicon Labs-owned bridge default PIDs + under VID 0x10C4. Product labels are conservative chip-family names. +* A first-party SiliconLabsSoftware Arduino example documents the Energy Micro + VID 0x2544 / PID 0x0001 udev rule required for OpenOCD access. + +Board names are intentionally not inferred from bridge-chip or debug-interface +IDs; board-package VID/PID rows for Arduino, Seeed, SparkFun, etc. belong to +their vendor-specific fetchers. + +Output schema: + { + "10c4:ea71": { + "vendor": "Silicon Labs", + "product": "CP2108 Quad UART Bridge" + } + } +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from collections import OrderedDict +from pathlib import Path +from typing import Callable + +SILABS_VENDOR = "Silicon Labs" +SILABS_VID = "10c4" +ENERGY_MICRO_VENDOR = "Energy Micro AS" +ENERGY_MICRO_VID = "2544" + +CP210X_DRIVER_URL = ( + "https://raw.githubusercontent.com/torvalds/linux/master/" + "drivers/usb/serial/cp210x.c" +) +SILABS_OPENOCD_README_URL = ( + "https://raw.githubusercontent.com/SiliconLabsSoftware/" + "devs-arduino-ble-motor-control/main/README.md" +) + +CP210X_PRODUCTS = { + "ea60": "CP210x UART Bridge", + "ea61": "CP210x UART Bridge", + "ea63": "CP210x UART Bridge", + "ea70": "CP2105 Dual UART Bridge", + "ea71": "CP2108 Quad UART Bridge", + "ea7a": "CP2105 Dual UART Bridge", + "ea7b": "CP2108 Quad UART Bridge", +} + +ENERGY_MICRO_PRODUCTS = { + "0001": "Silicon Labs OpenOCD debug interface", +} + +_USB_DEVICE_RE = re.compile( + r"USB_DEVICE(?:_INTERFACE_NUMBER)?\(\s*0x(?P[0-9A-Fa-f]{4})\s*,\s*" + r"0x(?P[0-9A-Fa-f]{4})" +) +_UDEV_ID_RE = re.compile( + r'ATTRS\{idVendor\}=="(?P[0-9A-Fa-f]{4})".*' + r'ATTRS\{idProduct\}=="(?P[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 parse_cp210x_driver_pids(text: str) -> set[str]: + """Parse Silicon Labs VID product IDs from Linux's CP210x driver.""" + pids: set[str] = set() + for match in _USB_DEVICE_RE.finditer(text): + if match.group("vid").lower() == SILABS_VID: + pids.add(match.group("pid").lower()) + return pids + + +def parse_energy_micro_udev_pids(text: str) -> set[str]: + """Parse Energy Micro VID product IDs from udev-rule snippets.""" + pids: set[str] = set() + for match in _UDEV_ID_RE.finditer(text): + if match.group("vid").lower() == ENERGY_MICRO_VID: + pids.add(match.group("pid").lower()) + return pids + + +def build_cp210x_supplement(driver_pids: set[str]) -> dict[str, str]: + """Return selected Silicon Labs CP210x defaults as `{pid: product}`.""" + missing = sorted(set(CP210X_PRODUCTS) - driver_pids) + if missing: + raise ValueError(f"Linux CP210x driver missing expected PIDs: {missing}") + return dict(sorted(CP210X_PRODUCTS.items())) + + +def build_energy_micro_supplement(udev_pids: set[str]) -> dict[str, str]: + """Return selected Energy Micro / Silicon Labs rows as `{pid: product}`.""" + missing = sorted(set(ENERGY_MICRO_PRODUCTS) - udev_pids) + if missing: + raise ValueError( + f"SiliconLabsSoftware udev snippet missing expected PIDs: {missing}" + ) + return dict(sorted(ENERGY_MICRO_PRODUCTS.items())) + + +def collect( + *, + fetch: Callable[[str], str] = _fetch, + cp210x_url: str = CP210X_DRIVER_URL, + openocd_readme_url: str = SILABS_OPENOCD_README_URL, +) -> dict[str, dict[str, str]]: + """Fetch Silicon Labs sources and emit merge-compatible entries.""" + entries: dict[str, dict[str, str]] = {} + + try: + cp210x_text = fetch(cp210x_url) + except Exception as e: + print(f"warning: {cp210x_url}: fetch failed: {e}", file=sys.stderr) + else: + pid_to_product = build_cp210x_supplement( + parse_cp210x_driver_pids(cp210x_text) + ) + entries.update( + { + f"{SILABS_VID}:{pid}": { + "vendor": SILABS_VENDOR, + "product": product, + } + for pid, product in pid_to_product.items() + } + ) + print( + f"linux cp210x: {cp210x_url}: {len(pid_to_product)} PID(s)", + file=sys.stderr, + ) + + try: + readme_text = fetch(openocd_readme_url) + except Exception as e: + print(f"warning: {openocd_readme_url}: fetch failed: {e}", file=sys.stderr) + else: + pid_to_product = build_energy_micro_supplement( + parse_energy_micro_udev_pids(readme_text) + ) + entries.update( + { + f"{ENERGY_MICRO_VID}:{pid}": { + "vendor": ENERGY_MICRO_VENDOR, + "product": product, + } + for pid, product in pid_to_product.items() + } + ) + print( + f"silabs openocd udev: {openocd_readme_url}: " + f"{len(pid_to_product)} 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)} Silicon Labs PID(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/online-data-tools/test_silabs_usb_pids.py b/online-data-tools/test_silabs_usb_pids.py new file mode 100644 index 00000000..dbe26e15 --- /dev/null +++ b/online-data-tools/test_silabs_usb_pids.py @@ -0,0 +1,143 @@ +#!/usr/bin/env -S uv run --no-project --with pytest --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pytest"] +# /// +"""Tests for fetch_silabs_usb_pids.py. + +Fixtures are small, network-free snippets from Linux `cp210x.c` and a +SiliconLabsSoftware README udev-rule block. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import fetch_silabs_usb_pids # noqa: E402 + + +CP210X_FIXTURE = """\ +static const struct usb_device_id id_table[] = { + { USB_DEVICE(0x10C4, 0xEA60) }, /* Silicon Labs factory default */ + { USB_DEVICE(0x10C4, 0xEA61) }, /* Silicon Labs factory default */ + { USB_DEVICE(0x10C4, 0xEA63) }, /* Silicon Labs Windows Update */ + { USB_DEVICE(0x10C4, 0xEA70) }, /* Silicon Labs factory default */ + { USB_DEVICE(0x10C4, 0xEA71) }, /* Infinity GPS-MIC-1 Radio Monophone */ + { USB_DEVICE(0x10C4, 0xEA7A) }, /* Silicon Labs Windows Update */ + { USB_DEVICE(0x10C4, 0xEA7B) }, /* Silicon Labs Windows Update */ + { USB_DEVICE(0x1234, 0xEA60) }, +}; +""" + +README_FIXTURE = """\ +1. (Optional) Setup udev rules to access openocd (Linux) + ```bash + SUBSYSTEM=="usb", ATTRS{idVendor}=="2544", ATTRS{idProduct}=="0001", GROUP="plugdev", TAG+="uaccess" + ``` +""" + + +def test_parse_cp210x_driver_pids_filters_to_silabs_vid() -> None: + pids = fetch_silabs_usb_pids.parse_cp210x_driver_pids(CP210X_FIXTURE) + assert "ea60" in pids + assert "ea71" in pids + assert len(pids) == 7 + + +def test_parse_energy_micro_udev_pids_reads_readme_rule() -> None: + pids = fetch_silabs_usb_pids.parse_energy_micro_udev_pids(README_FIXTURE) + assert pids == {"0001"} + + +def test_build_cp210x_supplement_returns_selected_bridge_rows() -> None: + pids = fetch_silabs_usb_pids.parse_cp210x_driver_pids(CP210X_FIXTURE) + out = fetch_silabs_usb_pids.build_cp210x_supplement(pids) + assert out["ea60"] == "CP210x UART Bridge" + assert out["ea70"] == "CP2105 Dual UART Bridge" + assert out["ea71"] == "CP2108 Quad UART Bridge" + assert out["ea7b"] == "CP2108 Quad UART Bridge" + + +def test_build_cp210x_supplement_rejects_missing_expected_pid() -> None: + pids = fetch_silabs_usb_pids.parse_cp210x_driver_pids( + CP210X_FIXTURE.replace(" { USB_DEVICE(0x10C4, 0xEA7B) },", "") + ) + with pytest.raises(ValueError, match="missing expected PIDs"): + fetch_silabs_usb_pids.build_cp210x_supplement(pids) + + +def test_build_energy_micro_supplement_labels_openocd_interface() -> None: + pids = fetch_silabs_usb_pids.parse_energy_micro_udev_pids(README_FIXTURE) + assert fetch_silabs_usb_pids.build_energy_micro_supplement(pids) == { + "0001": "Silicon Labs OpenOCD debug interface" + } + + +def test_collect_emits_merge_sources_shape() -> None: + fixtures = { + "cp210x": CP210X_FIXTURE, + "readme": README_FIXTURE, + } + + def fake_fetch(url: str) -> str: + return fixtures[url] + + out = fetch_silabs_usb_pids.collect( + fetch=fake_fetch, + cp210x_url="cp210x", + openocd_readme_url="readme", + ) + assert out["10c4:ea71"] == { + "vendor": "Silicon Labs", + "product": "CP2108 Quad UART Bridge", + } + assert out["2544:0001"] == { + "vendor": "Energy Micro AS", + "product": "Silicon Labs OpenOCD debug interface", + } + assert list(out) == sorted(out) + + +def test_collect_keeps_cp210x_rows_when_openocd_source_fails() -> None: + def fake_fetch(url: str) -> str: + if url == "readme": + raise OSError("offline") + return CP210X_FIXTURE + + out = fetch_silabs_usb_pids.collect( + fetch=fake_fetch, + cp210x_url="cp210x", + openocd_readme_url="readme", + ) + assert "10c4:ea60" in out + assert "2544:0001" not in out + + +def test_main_emits_merge_sources_shape(tmp_path: Path) -> None: + out = tmp_path / "silabs.json" + old_collect = fetch_silabs_usb_pids.collect + try: + fetch_silabs_usb_pids.collect = lambda: { + "2544:0001": { + "vendor": "Energy Micro AS", + "product": "Silicon Labs OpenOCD debug interface", + } + } + sys.argv = ["fetch_silabs_usb_pids.py", "--out", str(out)] + assert fetch_silabs_usb_pids.main() == 0 + finally: + fetch_silabs_usb_pids.collect = old_collect + + data = json.loads(out.read_text(encoding="utf-8")) + assert data == { + "2544:0001": { + "vendor": "Energy Micro AS", + "product": "Silicon Labs OpenOCD debug interface", + } + }