diff --git a/docs/package.json b/docs/package.json index 04372fad4aeb..4fbbf7b4ea1b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,8 +13,14 @@ "docs:gen_alt_sidebar_ubuntu": "python3 ./scripts/gen_alt_sidebar.py", "docs:get_alt_sidebar_windows": "python ./scripts/gen_alt_sidebar.py", "start": "yarn docs:dev", - "linkcheck": "markdown_link_checker_sc -r .. -d docs -e en -i assets -u docs.px4.io", - "build_docs_metadata_ubuntu": "(cd .. && Tools/ci/metadata_sync.sh --generate && Tools/ci/metadata_sync.sh --sync) && echo 'NOTE: These metadata changes are for local testing only and do not need to be merged.'" + "linkcheck": "markdown_link_checker_sc -r .. -d docs -e en -i assets", + "build_docs_metadata_ubuntu": "(cd .. && Tools/ci/metadata_sync.sh --generate && Tools/ci/metadata_sync.sh --sync) && echo 'NOTE: These metadata changes are for local testing only and do not need to be merged.'", + "gen_fc_sections": "python3 ./scripts/fc_doc_generator/fc_doc_generator.py --apply", + "new_fc_doc": "python3 ./scripts/fc_doc_generator/fc_doc_generator.py --new-doc --since-version v1.18", + "check_fc_doc": "python3 ./scripts/fc_doc_generator/fc_doc_generator.py --check-doc", + "check_fc_docs": "python3 ./scripts/fc_doc_generator/fc_doc_generator.py --check-all", + "test_fc_doc": "python3 -m pytest scripts/fc_doc_generator/ -v", + "update_fc_snapshots": "python3 -m pytest scripts/fc_doc_generator/ -v --update-snapshots" }, "dependencies": { "@red-asuka/vitepress-plugin-tabs": "0.0.4", diff --git a/docs/scripts/fc_doc_generator/CLAUDE.md b/docs/scripts/fc_doc_generator/CLAUDE.md new file mode 100644 index 000000000000..1f51de987874 --- /dev/null +++ b/docs/scripts/fc_doc_generator/CLAUDE.md @@ -0,0 +1,187 @@ +# fc_doc_generator + +Auto-generates flight controller documentation sections for PX4-Autopilot +from board source files (`boards///`). + +## What it does + +Parses PX4 board C/Kconfig source files and generates Markdown sections +inserted into `docs/en/flight_controller/*.md` docs. Sections generated: + +- `## Specifications` — processor, sensors, interfaces +- `## PWM Outputs` — timer groups, DShot/BDShot capability per output +- `## Serial` — UART → /dev/ttyS* mapping with labels and flow control +- `## Radio Control` — RC input protocols and ports +- `## GPS & Compass` — GPS/safety connector info +- `## Power` — power input ports and monitor type +- `## Voltage Ratings` — per-port operating/absolute voltage ranges, servo rail, voltage monitoring note +- `## Telemetry Radios` — TELEM port listing +- `## Ethernet` — speed, transformerless flag, and link to setup guide (boards with `CONFIG_BOARD_ETHERNET=y`) +- `## SD Card` — presence/absence + +## File layout + +``` +fc_doc_generator/ +├── fc_doc_generator.py # Main script (~3700 lines) +├── pytest.ini # testpaths = tests +├── metadata/ # Per-board cached JSON (data + wizard overrides) +│ ├── __data.json # Parsed board data +│ └── __wizard.json # User-supplied wizard overrides +└── tests/ + ├── conftest.py # snapshot fixture + board_* path fixtures + ├── fixtures/ # Minimal fake board trees + │ ├── stm32h7_all_dshot/ + │ ├── stm32h7_mixed_io/ + │ ├── stm32h7_ppm_shared/ + │ ├── stm32h7_capture_channels/ # 8 regular outputs + 8 initIOTimerChannelCapture inputs (excluded) + │ ├── stm32f4_no_dshot/ + │ └── imxrt_all_dshot/ + ├── snapshots/ # Expected markdown output (.md files) + ├── test_parsers.py # Unit tests for parse_* functions + ├── test_compute.py # Unit tests for compute_groups / compute_bdshot + ├── test_generators.py # Snapshot tests for generate_*_section functions + ├── test_helpers.py # Unit tests for helper functions + └── test_wizard.py # Tests for wizard cache and generate_full_template +``` + +## Running the script + +Run from the repo root (requires the PX4 `boards/` tree to be present): + +```sh +# Generate metadata JSON + fc_sections.md (no file edits): +python docs/scripts/fc_doc_generator/fc_doc_generator.py + +# Apply sections to existing FC docs: +python docs/scripts/fc_doc_generator/fc_doc_generator.py --apply + +# Apply a single section only: +python docs/scripts/fc_doc_generator/fc_doc_generator.py --apply --section pwm_outputs + +# Apply all sections to a single doc only (stem or filename, implies --apply): +python docs/scripts/fc_doc_generator/fc_doc_generator.py --doc cuav_x25-evo.md + +# Apply a single section to a single doc: +python docs/scripts/fc_doc_generator/fc_doc_generator.py --doc cuav_x25-evo.md --section pwm_outputs + +# Create a new stub FC doc (interactive wizard): +python docs/scripts/fc_doc_generator/fc_doc_generator.py --new-doc + +# Check a single doc against quality specs: +python docs/scripts/fc_doc_generator/fc_doc_generator.py --check-doc docs/en/flight_controller/holybro_kakuteh7.md + +# Check all FC docs: +python docs/scripts/fc_doc_generator/fc_doc_generator.py --check-all +``` + +Via yarn (from the `docs/` directory): `cd docs && yarn gen_fc_sections` + +## Running tests + +From `docs/scripts/fc_doc_generator/`: + +```sh +pytest # run all tests +pytest --update-snapshots # regenerate snapshot files after intentional changes +pytest tests/test_generators.py # specific test file +``` + +## Snapshot tests + +Tests in `test_generators.py` use the `snapshot` fixture from `conftest.py`. +- Snapshot files live in `tests/snapshots/*.md` +- To add a new snapshot test: call `snapshot("my_name.md", result)` — then run `pytest --update-snapshots` to create the file +- After intentional generator changes: run `pytest --update-snapshots` then review diffs with `git diff tests/snapshots/` + +## Extension pattern (adding a new section) + +1. Write `parse_(board_path: Path) -> dict` and call it in `gather_board_data()`, merging into the entry +2. Write `generate__section(board_key, entry) -> str` +3. Register both in `SECTION_GENERATORS` and `SECTION_ORDER` +4. Add snapshot tests in `test_generators.py` +5. Re-run `cd docs && yarn gen_fc_sections` to regenerate metadata JSON + `fc_sections.md` + +## Key architecture notes + +- **Parsers** read from `boards///` source files: + - `nuttx-config/nsh/defconfig` — chip family, enabled UARTs, SD card + - `src/board_config.h` — PWM count, IO board presence, GPIOs + - `src/timer_config.cpp` — timer groups and channels; `initIOTimerChannelCapture` + entries are **excluded** from the FMU output count — these are timer-capture + inputs (e.g. RC PPM, pulse-width measurement), not usable as PWM outputs. + They are stored separately in `capture_channels` in the metadata JSON for use + in other documentation sections. + - `default.px4board` — Kconfig board settings (serial labels, RC, GPS, drivers) + - `nuttx-config/include/board.h` — flow control GPIO definitions + - `init/rc.board_sensors` — sensor driver start commands; comments immediately + preceding a sensor start line are parsed for port labels (e.g. + `# External compass on GPS1/I2C1:` → `port_label: 'GPS1'` on that sensor entry); + power monitor drivers (INA226/INA228/INA238) are also captured in + `sensor_bus_info['power_monitor']` + - `src/i2c.cpp` — authoritative I2C bus routing: + `initI2CBusExternal(N)` = external connector; `initI2CBusInternal(N)` = on-board only. + When present, stored in `i2c_bus_config` and enables the detailed per-bus I2C output. + +- **`DSHOT_UNSUPPORTED_FAMILIES`** — constant listing chip families where PX4 firmware + does not support DShot (currently `stm32f4`, `stm32f7`). Both use DMA IP V1; PX4's + `dshot.c` skips DMA V1 MCUs entirely. `compute_groups()` suppresses DShot for all + groups when the board's family is in this set, regardless of DMA presence in + `timer_config.cpp`. + +- **Metadata JSON** in `metadata/` caches parsed data (`*_data.json`) and + wizard-supplied overrides (`*_wizard.json`). Wizard data persists across runs + and provides connector types, sensor names, dimensions, etc. + +- **`BOARD_TO_DOC`** — static mapping from `vendor/board` key to doc filename. + Boards mapped to `None` have no existing doc page yet. + +- **Section insertion** — `_apply_section()` finds existing headings and + replaces them, or inserts before anchor headings like `## Where to Buy`. + - The `specifications` section always preserves hand-written content and + appends generated content as a `` comment. + - All other sections check whether the existing heading contains the + `{#section_key}` anchor (e.g. `{#pwm_outputs}`). If it does, the section + was previously generated and is safely replaced. If not, the section is + hand-written: it is preserved and the proposed content is appended as a + `` comment, with a console warning. + +- **`parse_power_config()` detects INA228** — `CONFIG_DRIVERS_POWER_MONITOR_INA238=y` takes + precedence, then `INA228`, then `INA226`. Old code silently promoted INA228 boards to INA226. + +- **Wizard** — `--new-doc` runs an interactive prompts session and caches + answers to `metadata/_wizard.json` for future re-use. + Key wizard fields and their shapes: + - `ethernet_wizard`: `{"port_label": str|null, "speed_mbps": str, "transformerless": bool}` — only + prompted when `CONFIG_BOARD_ETHERNET=y`; `port_label` is rendered as inline code (backticks) in the + generated section. `apply_sections_to_docs()` bootstraps this from the wizard JSON on first run + when the doc's `` block doesn't yet contain it. + - `power_ports_wizard[].normal_min_v` / `normal_max_v` / `absolute_max_v` (str|null) — per-port + voltage ranges for the `## Voltage Ratings` section. Old wizard JSONs without these keys are safe; + missing values produce `TODO` placeholders in the generated section. + - `overview_wizard.servo_rail_absolute_max_v` (str|null) — undamaged absolute maximum for the servo + rail (e.g. `"42"` for Pixhawk-standard boards). Distinct from `servo_rail_max_v` (normal operating + max used in Specifications). Missing → `TODO` placeholder in Voltage Ratings. + +## Conventions + +- British English in doc output +- Asset files lowercase with underscores; asset folder named after doc stem +- Section generators emit embedded `` JSON comments + so the raw parsed values are visible in the doc for manual verification +- `TODO:` placeholders are left wherever data cannot be auto-detected + +## Development rules + +**When modifying `fc_doc_generator.py`:** +1. All existing tests must pass: run `pytest` from `docs/scripts/fc_doc_generator/` +2. New functionality must have new tests (unit tests and/or snapshot tests as appropriate) +3. Update this `CLAUDE.md` if the change affects how to run the script, the architecture, extension patterns, or conventions +4. **Wizard JSON backward compatibility** — `metadata/*_wizard.json` files are persisted user + data. A new tool version must work correctly with an old wizard JSON: + - **Adding** a new field to `_WIZARD_CACHE_FIELDS`: safe — missing keys are read via + `cached.get(...)` which returns `None`, triggering re-prompting. + - **Renaming** an existing field or **changing its structure** (e.g. the shape of + `i2c_buses_wizard` entries): **breaking change** — old data is silently lost or + misinterpreted. This must be explicitly flagged in the plan and requires a migration + strategy (e.g. read both old and new key names, or add a one-time upgrade step). diff --git a/docs/scripts/fc_doc_generator/fc_doc_generator.py b/docs/scripts/fc_doc_generator/fc_doc_generator.py new file mode 100644 index 000000000000..15699e300207 --- /dev/null +++ b/docs/scripts/fc_doc_generator/fc_doc_generator.py @@ -0,0 +1,5299 @@ +#!/usr/bin/env python3 +""" +FC Doc Generator — parse PX4 board source files and generate flight controller +documentation sections. + +Currently generates: ## Specifications, ## PWM Outputs, ## Radio Control, ## GPS & Compass + +Extension pattern — to add a new section: + 1. Write parse_(board_path) -> dict and call it in gather_board_data(), + merging the result into the board entry. + 2. Write generate__section(board_key, entry) -> str. + 3. Register both in SECTION_GENERATORS and SECTION_ORDER below. + 4. Re-run fc_doc_generator.py (yarn gen_fc_sections) to regenerate metadata/ JSON + fc_sections.md and apply to docs. +""" + +import argparse +import re +import json +from pathlib import Path + + +def _find_repo_root(start: Path) -> Path: + """Walk up from start until a directory containing 'boards/' is found.""" + for p in [start, *start.parents]: + if (p / "boards").is_dir(): + return p + raise FileNotFoundError(f"Could not find PX4 repo root (no 'boards/' dir) from {start}") + + +def _make_slug(s: str) -> str: + """Lowercase, collapse spaces/hyphens to underscores, strip non-ASCII.""" + s = ''.join(c for c in s if ord(c) < 128) # ASCII-only (removes surrogates too) + return re.sub(r'[\s\-]+', '_', s.strip().lower()) + + +def _doc_filename(manufacturer: str, product: str, fmu_version: str = None) -> str: + """holybro + Pixhawk 6X + fmu-v6x → holybro_pixhawk_6x_fmu_v6x.md""" + parts = [_make_slug(manufacturer), _make_slug(product)] + if fmu_version: + parts.append(_make_slug(fmu_version)) + return "_".join(parts) + ".md" + + +SCRIPT_DIR = Path(__file__).resolve().parent +METADATA_DIR = SCRIPT_DIR / "metadata" +REPO = _find_repo_root(SCRIPT_DIR) +BOARDS = REPO / "boards" +FC_DOCS = REPO / "docs/en/flight_controller" + +# --------------------------------------------------------------------------- +# Serial UART hardware ordering by chip family. +# Matches the order NuttX registers devices: console → /dev/ttyS0, then the +# rest in this order as /dev/ttyS1, /dev/ttyS2, … +# --------------------------------------------------------------------------- +UART_HW_ORDER = { + 'stm32h7': ['USART1', 'USART2', 'USART3', 'UART4', 'UART5', 'USART6', 'UART7', 'UART8'], + 'stm32f7': ['USART1', 'USART2', 'USART3', 'UART4', 'UART5', 'USART6', 'UART7', 'UART8'], + 'stm32f4': ['USART1', 'USART2', 'USART3', 'UART4', 'UART5', 'USART6'], + 'stm32f1': ['USART1', 'USART2', 'USART3', 'UART4', 'UART5'], + 'imxrt': ['LPUART1', 'LPUART2', 'LPUART3', 'LPUART4', + 'LPUART5', 'LPUART6', 'LPUART7', 'LPUART8', + 'LPUART9', 'LPUART10', 'LPUART11'], +} + +# --------------------------------------------------------------------------- +# BDShot per-channel capture support by chip family and timer +# Source: hw_description.h getTimerChannelDMAMap() per family +# For each timer, list which channel indices (0-based) support capture DMA. +# None = timer not in map at all (no capture) +# --------------------------------------------------------------------------- +BDSHOT_CAPTURE_MAP = { + "stm32h7": { + # All 4 channels have dma_map_ch except Timer4 ch3 (index 3 = CH4) = 0 + "Timer1": [0, 1, 2, 3], + "Timer2": [0, 1, 2, 3], + "Timer3": [0, 1, 2, 3], + "Timer4": [0, 1, 2], # CH4 (index 3) = 0 + "Timer5": [0, 1, 2, 3], + "Timer8": [0, 1, 2, 3], + "Timer15": [0], + "Timer16": [0], + "Timer17": [0], + }, + "stm32f7": { + # DShot not supported on F7 (DMA IP V1); no BDShot either + "_none": True, + }, + "stm32f4": { + # getTimerChannelDMAMap is explicitly "Not supported" — no BDShot at all + "_none": True, + }, + "stm32f1": { + # Not supported + "_none": True, + }, + "imxrt": { + # All outputs support BDShot (docs: "All FMU outputs" on iMXRT) + "_all": True, + }, +} + +# Chip families where DShot is not supported in PX4 firmware. +# Both STM32F4 and STM32F7 use DMA IP V1; PX4's dshot.c skips these MCUs entirely +# (#if CONFIG_STM32_HAVE_IP_DMA_V1 → do nothing). Timer DMA presence in +# timer_config.cpp is therefore not sufficient to infer DShot capability on these +# families — the suppression is applied in compute_groups(). +DSHOT_UNSUPPORTED_FAMILIES = frozenset({'stm32f4', 'stm32f7'}) + +# --------------------------------------------------------------------------- +# Chip model → hardware specification lookup +# Keyed by model number without package/revision suffix (e.g. "STM32H743"). +# Used by generate_specifications_section() to produce the Processor bullet. +# --------------------------------------------------------------------------- +CHIP_SPECS = { + # STM32H7 family — Cortex-M7, 480 MHz, 2MB flash, 1MB RAM + 'STM32H743': {'core': 'Cortex®-M7', 'mhz': 480, 'flash': '2MB', 'ram': '1MB'}, + 'STM32H753': {'core': 'Cortex®-M7', 'mhz': 480, 'flash': '2MB', 'ram': '1MB'}, + 'STM32H747': {'core': 'Cortex®-M7', 'mhz': 480, 'flash': '2MB', 'ram': '1MB'}, + 'STM32H757': {'core': 'Cortex®-M7', 'mhz': 480, 'flash': '2MB', 'ram': '1MB'}, + # STM32F7 family — Cortex-M7, 216 MHz + 'STM32F765': {'core': 'Cortex®-M7', 'mhz': 216, 'flash': '2MB', 'ram': '512KB'}, + 'STM32F767': {'core': 'Cortex®-M7', 'mhz': 216, 'flash': '2MB', 'ram': '512KB'}, + 'STM32F777': {'core': 'Cortex®-M7', 'mhz': 216, 'flash': '2MB', 'ram': '512KB'}, + # STM32F4 family — Cortex-M4, 168 MHz + 'STM32F427': {'core': 'Cortex®-M4', 'mhz': 168, 'flash': '2MB', 'ram': '256KB'}, + 'STM32F437': {'core': 'Cortex®-M4', 'mhz': 168, 'flash': '2MB', 'ram': '256KB'}, + 'STM32F407': {'core': 'Cortex®-M4', 'mhz': 168, 'flash': '1MB', 'ram': '192KB'}, + 'STM32F405': {'core': 'Cortex®-M4', 'mhz': 168, 'flash': '1MB', 'ram': '192KB'}, + # iMXRT family — Cortex-M7, 600 MHz + 'MIMXRT1062': {'core': 'Cortex®-M7', 'mhz': 600, 'flash': '8MB', 'ram': '1MB'}, +} + +# IO co-processor spec (always STM32F100 on PX4 io-v2) +_IO_PROC_SPEC = 'STM32F100 (32-bit Arm® Cortex®-M3, 24 MHz, 8KB SRAM)' + + +def get_chip_family(board_path: Path) -> str: + """Detect the MCU chip family for a board. + + Mechanism + --------- + Primary source: ``nuttx-config/nsh/defconfig`` + NuttX's Kconfig system sets exactly one ``CONFIG_ARCH_CHIP_*`` symbol for + the target MCU. The symbols are mutually exclusive, so the first match is + authoritative. Patterns checked (in order): + + CONFIG_ARCH_CHIP_STM32H7 → 'stm32h7' + CONFIG_ARCH_CHIP_STM32F7 → 'stm32f7' + CONFIG_ARCH_CHIP_STM32F → 'stm32f4' (F4/F3/F2 era, treated as F4) + CONFIG_ARCH_CHIP_STM32=y → 'stm32f1' + IMXRT (case-insensitive) → 'imxrt' + + Fallback: scan any ``CMakeLists.txt`` under the board directory for the + string "imxrt" (catches boards that configure the chip via cmake rather + than Kconfig). + + Returns 'unknown' when no pattern matches; callers that depend on family + (e.g. BDSHOT_CAPTURE_MAP, UART_HW_ORDER) will produce empty output rather + than incorrect data. + """ + defconfig = board_path / "nuttx-config/nsh/defconfig" + if defconfig.exists(): + text = defconfig.read_text(errors="ignore") + if "CONFIG_ARCH_CHIP_STM32H7" in text: + return "stm32h7" + if "CONFIG_ARCH_CHIP_STM32F7" in text: + return "stm32f7" + if re.search(r'CONFIG_ARCH_CHIP_STM32F[0-9]', text): + return "stm32f4" # F4 and F1 era + if re.search(r'CONFIG_ARCH_CHIP_STM32=y|CONFIG_ARCH_CHIP_STM32F1', text): + return "stm32f1" + if re.search(r'IMXRT|imxrt', text, re.IGNORECASE): + return "imxrt" + # Try cmake + for cmake in board_path.rglob("CMakeLists.txt"): + txt = cmake.read_text(errors="ignore") + if "imxrt" in txt.lower(): + return "imxrt" + return "unknown" + + +def parse_chip_variant(board_path: Path) -> dict: + """Extract the exact MCU chip model from nuttx-config/nsh/defconfig. + + Reads ``CONFIG_ARCH_CHIP_=y`` and strips the 2-character package + suffix to produce a model string suitable as a key into CHIP_SPECS. + + Examples + -------- + CONFIG_ARCH_CHIP_STM32H743VI=y → chip_model='STM32H743' + CONFIG_ARCH_CHIP_STM32F427II=y → chip_model='STM32F427' + CONFIG_ARCH_CHIP_MIMXRT1062=y → chip_model='MIMXRT1062' + + Returns + ------- + {'chip_model': str | None, 'chip_variant_full': str | None} + """ + defconfig = board_path / 'nuttx-config/nsh/defconfig' + if defconfig.exists(): + text = defconfig.read_text(errors='ignore') + # STM32 variants: CONFIG_ARCH_CHIP_STM32H743VI=y + m = re.search(r'CONFIG_ARCH_CHIP_(STM32[A-Z0-9]+)=y', text) + if m: + full = m.group(1) # e.g. "STM32H743VI" + # Strip 2-char package/revision suffix for model lookup + model = full[:-2] if len(full) > 8 else full + return {'chip_model': model, 'chip_variant_full': full} + # iMXRT variants: CONFIG_ARCH_CHIP_MIMXRT1062CVL5A=y + m = re.search(r'CONFIG_ARCH_CHIP_(MIMXRT[A-Z0-9]+)=y', text) + if m: + full = m.group(1) + # iMXRT model = first 10 chars (e.g. "MIMXRT1062") + model = full[:10] + return {'chip_model': model, 'chip_variant_full': full} + return {'chip_model': None, 'chip_variant_full': None} + + +def parse_board_config(board_path: Path) -> dict: + """Parse PWM output count and IO-board presence from board_config.h. + + Mechanism + --------- + Source file: ``src/board_config.h`` + + **Total FMU PWM outputs** — macro ``DIRECT_PWM_OUTPUT_CHANNELS``: + #define DIRECT_PWM_OUTPUT_CHANNELS 6 + This is the number of PWM lines driven directly by the FMU (i.e. the AUX + outputs on boards with an IO board, or MAIN outputs on boards without one). + + **IO board detection** — presence of any of: + PX4IO_SERIAL, HAVE_PX4IO, PX4IO_DEVICE_PATH + These are defined whenever the board routes a UART to a co-processor + running PX4IO firmware (the io-v2 board), which provides 8 additional + "MAIN" PWM outputs. The IO output count is therefore hardcoded to 8 + (the io-v2 standard) and does not need to be parsed. + + Returns a dict with keys: + total_outputs int — FMU PWM channel count (may be absent if not found) + has_io_board bool — True if a PX4IO co-processor is present + io_outputs int — 8 if has_io_board, else 0 + """ + cfg = board_path / "src/board_config.h" + if not cfg.exists(): + return {} + text = cfg.read_text(errors="ignore") + result = {} + m = re.search(r'#define\s+DIRECT_PWM_OUTPUT_CHANNELS\s+(\d+)', text) + if m: + result["total_outputs"] = int(m.group(1)) + m = re.search(r'#define\s+BOARD_NUM_IO_TIMERS\s+(\d+)', text) + if m: + result["num_timers"] = int(m.group(1)) + # Check for IO board (MAIN outputs) + if "PX4IO_SERIAL" in text or "HAVE_PX4IO" in text or "PX4IO_DEVICE_PATH" in text: + result["has_io_board"] = True + # IO board version 2 always has 8 outputs; detect version for future-proofing + m = re.search(r'#define\s+BOARD_USES_PX4IO_VERSION\s+(\d+)', text) + result["io_outputs"] = 8 # io-v2 standard + else: + result["has_io_board"] = False + result["io_outputs"] = 0 + return result + + +def parse_timer_config(board_path: Path) -> dict: + """Parse PWM timer groups and output channels from timer_config.cpp. + + Mechanism + --------- + Source file: ``src/timer_config.cpp`` + + PX4 defines PWM outputs through two compile-time ``constexpr`` arrays: + + **io_timers[]** — one entry per hardware timer used for PWM. + Each entry is constructed by one of: + initIOTimer(Timer::Timer5) — no DShot DMA + initIOTimer(Timer::Timer5, DMA{DMA::Index1, …}) — DShot capable + + The presence of a ``DMA{…}`` argument is the indicator that the entire + timer group supports DShot. The timer name (e.g. ``Timer5``) becomes the + group identifier. + + **timer_io_channels[]** — one entry per PWM output channel, in output order + (FMU_CH1, FMU_CH2, …). Each entry is constructed by one of: + initIOTimerChannel(io_timers, {Timer::Timer5, Timer::Channel1}, …) + initIOTimerChannelOutputClear(…) — same semantics, clears on disable + initIOTimerChannelDshot(…) — iMXRT only: marks channel as DShot + initIOTimerChannelCapture(…) — dual-purpose capture/output channel + + The channel index (Channel1 = index 0, Channel2 = index 1, …) is extracted + and stored for use by compute_bdshot() when determining per-channel BDShot + DMA capability. Channels are counted sequentially to assign output numbers. + + **iMXRT special case** — NXP iMXRT boards use a PWM peripheral with a + different API: timer names are ``PWM1_PWM_A``, ``PWM1_PWM_B``, etc. + If io_timers[] is empty after parsing (no ``initIOTimer`` calls), the timer + list is reconstructed by grouping channels by their ``PWMn`` prefix. + DShot capability is inferred from any channel in the group having + ``initIOTimerChannelDshot``. + + Returns {'timers': [...], 'channels': [...]} + """ + tc = board_path / "src/timer_config.cpp" + if not tc.exists(): + return {} + + text = tc.read_text(errors="ignore") + + # Extract comment block for channel assignments (if present) + comment_assignments = [] + for line in text.split('\n'): + # e.g. "* TIM5_CH4 T FMU_CH1" + m = re.match(r'\s*\*\s+(TIM\w+_CH\w+)\s+\w+\s+(FMU_CH\d+)', line) + if m: + comment_assignments.append((m.group(1), m.group(2))) + + # Parse io_timers[] array entries + # Handle: initIOTimer(Timer::Timer5, DMA{DMA::Index1}) + # Handle: initIOTimer(Timer::Timer3, DMA{DMA::Index1, DMA::Stream2, DMA::Channel5}) + # Handle: initIOTimer(Timer::Timer12) -- no DMA + timers = [] + # Find the io_timers[] array block + io_timers_match = re.search( + r'constexpr\s+io_timers_t\s+io_timers\[.*?\]\s*=\s*\{(.*?)\};', + text, re.DOTALL + ) + if io_timers_match: + block = re.sub(r'//[^\n]*', '', io_timers_match.group(1)) + for entry in re.finditer(r'initIOTimer\s*\(\s*Timer::(\w+)(.*?)\)', block): + timer_name = entry.group(1) + rest = entry.group(2).strip() + has_dma = bool(re.search(r'DMA\s*\{', rest)) + timers.append({ + "timer": timer_name, + "dshot": has_dma, + }) + + # Parse timer_io_channels[] array entries + # Handle: initIOTimerChannel(io_timers, {Timer::Timer5, Timer::Channel1}, ...) + # Handle: initIOTimerChannelOutputClear(...), initIOTimerChannelDshot(...) + # Handle: initIOTimerChannelCapture(...) -- timer-capture inputs (RC PPM, FMU_CAP*, etc.) + channels = [] + capture_channels = [] + io_channels_match = re.search( + r'constexpr\s+timer_io_channels_t\s+timer_io_channels\[.*?\]\s*=\s*\{(.*?)\};', + text, re.DOTALL + ) + if io_channels_match: + # Strip C++ line comments to avoid parsing commented-out channels + block = re.sub(r'//[^\n]*', '', io_channels_match.group(1)) + output_idx = 1 + for entry in re.finditer( + r'(initIOTimerChannel(?:OutputClear|Dshot|Capture)?)\s*\(\s*io_timers\s*,\s*\{(?:Timer|PWM)::(\w+)\s*,\s*(?:Timer::|PWM::)?(\w+)\}', + block + ): + func_name = entry.group(1) + timer_name = entry.group(2) + channel_name = entry.group(3) + is_dshot = "Dshot" in func_name + # Skip channel mapping entries (not actual channels) + if "Mapping" in func_name: + continue + # Extract channel number (shared by both output and capture paths) + ch_num_match = re.search(r'Channel(\d+)|Submodule(\d+)|CH(\d+)', channel_name) + ch_idx = None + if ch_num_match: + ch_idx = int(next(g for g in ch_num_match.groups() if g is not None)) - 1 + # Collect input-capture channels separately — these are timer-capture inputs + # (e.g. RC PPM, FMU_CAP*, pulse-width measurement), not PWM output pins. + # They are NOT counted as outputs but are stored for use in other doc sections. + if "Capture" in func_name: + capture_channels.append({ + "timer": timer_name, + "channel": channel_name, + "channel_index": ch_idx, + }) + continue + channels.append({ + "output_index": output_idx, + "timer": timer_name, + "channel": channel_name, + "channel_index": ch_idx, + "is_dshot_channel": is_dshot, # iMXRT only + }) + output_idx += 1 + + # For NXP iMXRT boards: timers are derived from unique PWM module names in channels + if not timers and channels: + seen = {} + for ch in channels: + # e.g. timer = "PWM1_PWM_A" -> group key = "PWM1" + m = re.match(r'(PWM\d+)', ch["timer"]) + key = m.group(1) if m else ch["timer"] + if key not in seen: + seen[key] = ch.get("is_dshot_channel", False) + elif ch.get("is_dshot_channel"): + seen[key] = True + for tname, has_dshot in seen.items(): + timers.append({"timer": tname, "dshot": has_dshot}) + + return { + "timers": timers, + "channels": channels, + "capture_channels": capture_channels, + } + + +def _serial_label(raw: str) -> str: + """Normalise a CONFIG_BOARD_SERIAL_* key to a display label. + + TEL1 → TELEM1, TEL2 → TELEM2, etc. All other keys are returned as-is. + """ + m = re.fullmatch(r'TEL(\d+)', raw) + return f'TELEM{m.group(1)}' if m else raw + + +def parse_serial_config(board_path: Path) -> dict: + """Parse serial port configuration, producing a UART → device → label mapping. + + Mechanism + --------- + + **Labels** — ``default.px4board`` (primary, authoritative source) + Each board's Kconfig file contains ``CONFIG_BOARD_SERIAL_