Skip to content

Commit f6c5522

Browse files
authored
Make board updates one step: update_board.py wrapper, single-board sync, ESPHome-version guard (#1653)
1 parent 64e9e36 commit f6c5522

7 files changed

Lines changed: 488 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,17 @@ against legacy behaviour before assuming the simpler version suffices.
349349
`_split_qualified_key` flips it.
350350
- **Board catalog** (`definitions/boards/<id>/manifest.yaml`) is
351351
hand-curated YAML. ~80 popular boards plus generic fallbacks per
352-
platform. `script/validate_definitions.py` lints the manifests.
352+
platform. `script/validate_definitions.py` lints the manifests. The
353+
manifest is the only hand-edited file; after editing one, run
354+
`script/update_board.py [board-id]` (auto-detects the edited board) to
355+
regenerate its JSON and validate in one step. It wraps
356+
`script/sync_boards.py <board-id>` (single board) / `sync_boards.py`
357+
(all) + `validate_definitions.py`. Single-board mode refuses unless the
358+
installed `esphome` matches the `esphome_version` stamped in
359+
`boards.index.json` by the last full sync (betas canonicalized to base, as
360+
it rebuilds the shared index from every board); a full sync regenerates
361+
everything from the installed esphome and re-stamps it, so it doesn't
362+
check. Full contributor workflow: `definitions/README.md`.
353363
- **Frontend handoff** for the catalog is documented inline in models
354364
(`ConfigEntry`, `ComponentCatalogEntry`). New `ConfigEntryType` values
355365
need a frontend update — coordinate.
@@ -611,7 +621,8 @@ When changing the sync script or catalog handling, watch for these:
611621
| `esphome_device_builder/definitions/boards/<id>/manifest.yaml` | Curated; hand-edited. The body directory is `board_bodies/` (separate from this manifests dir) so the body-swap rmtree can't trample the hand-curated source. |
612622
| `esphome_device_builder/definitions/platform_capabilities.index.json` | Generated; do not hand-edit. esphome platform metadata the long-lived process reads instead of importing `esphome.components.*` (download routing, wifi-inference no-wifi sets, static download-types). Loaded via `load_platform_capabilities_index`. |
613623
| `esphome_device_builder/helper_cli.py` (`device-builder-helper`) | Subprocess for `get_download_types` on build-dir-dependent platforms (libretiny/nrf52), so the child imports `esphome.components.<X>`, not the dashboard process. |
614-
| `script/sync_boards.py` | Regenerates the split board catalog from the manifests |
624+
| `script/update_board.py` | One-step contributor wrapper: regenerate one board's JSON (`sync_boards.py`) + validate (`validate_definitions.py`). Auto-detects the edited board, or takes an id. |
625+
| `script/sync_boards.py` | Regenerates the split board catalog from the manifests; stamps the generating `esphome_version` into `boards.index.json`. Takes an optional board id to regenerate just one (single-board mode guards installed `esphome` against that stamp). |
615626
| `script/sync_components.py` | Regenerates the component catalog + `platform_capabilities.index.json` |
616627
| `script/check_catalog.py` | Smoke test for popular components |
617628
| `script/check_import_time.py` | CI guard: fails if `import …device_builder` regresses past `script/import_time_budget.json` (e.g. a fresh eager `esphome.components.*` import) |

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,9 @@ receiver is paired.
487487
- **[docs/API.md](docs/API.md)** — every WebSocket command, request/response
488488
shapes, event types.
489489
- **[esphome_device_builder/definitions/README.md](esphome_device_builder/definitions/README.md)**
490-
contributor guide for board manifests.
490+
board (and component) contributor guide: manifest schema plus the
491+
workflow for adding or updating a board (edit the manifest, then run
492+
`python script/update_board.py` to regenerate and validate).
491493

492494
## Contributing
493495

esphome_device_builder/definitions/README.md

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,88 @@
22

33
This directory contains the board and component definitions used by the ESPHome Device Builder.
44

5+
## Editing boards: the build pipeline
6+
7+
> **Read this first.** Editing a `manifest.yaml` is only half the job; if you
8+
> stop there, your change never reaches the dashboard and CI fails.
9+
10+
The `manifest.yaml` files under `boards/<id>/` are the only thing you hand-edit.
11+
The dashboard does **not** read them at runtime. It reads three generated
12+
artefacts, all written by `script/sync_boards.py`:
13+
14+
| Artefact | What it is |
15+
|----------|------------|
16+
| `boards.index.json` | Slim per-board index; powers the board picker. |
17+
| `board_bodies/<id>.json` | Full body (hardware, pins, featured components); lazy-loaded when a board is opened. |
18+
| `featured_components.index.json` | Aggregated featured-components map read once at startup. |
19+
20+
After editing a manifest, run the one-step helper from the project venv:
21+
22+
```bash
23+
python script/update_board.py # auto-detects the board you edited
24+
python script/update_board.py my-board # or name it explicitly
25+
```
26+
27+
It regenerates that board's JSON, validates the definitions, and prints the
28+
files to commit. That is all most contributors need.
29+
30+
Under the hood it runs the two scripts you can also call directly:
31+
32+
```bash
33+
# regenerate the JSON. Pass a board id to rewrite just that board; omit it
34+
# to rebuild the whole catalog (~990 body files).
35+
python script/sync_boards.py my-board
36+
37+
# validate the manifests against the schema and cross-references
38+
python script/validate_definitions.py
39+
```
40+
41+
Passing the board id (the folder name under `boards/`) rewrites only that
42+
board's `board_bodies/<id>.json` and refreshes the two index files, so the diff
43+
stays scoped to what you edited.
44+
45+
Commit the manifest **and** the regenerated JSON together. **Never hand-edit the
46+
JSON** (`boards.index.json`, `board_bodies/*.json`); it is overwritten on the
47+
next sync, and `tests/test_boards_json.py::test_split_artefacts_match_manifests`
48+
compares every manifest against its generated body and fails CI on any drift.
49+
50+
`validate_definitions.py --check-images` additionally fetches each image URL to
51+
confirm it resolves (network, opt-in); the consistency test exempts `images`, so
52+
a broken image URL passes the sync but is caught here.
53+
54+
### Run the sync with the project venv
55+
56+
`sync_boards.py` imports ESPHome: it generates the boards no manifest covers
57+
straight from your installed ESPHome's board tables, and fills curated boards'
58+
pin aliases the same way. Run it from the project venv:
59+
60+
```bash
61+
source .venv/bin/activate # or call .venv/bin/python directly
62+
python script/sync_boards.py my-board
63+
```
64+
65+
Single-board mode (and `update_board.py`) rewrites one body but rebuilds the
66+
shared index from every board, so the installed ESPHome must match the version
67+
the committed catalog was generated against (stamped as `esphome_version` in
68+
`boards.index.json` by the last full sync, betas canonicalized to their base
69+
release) or their index entries silently drift; it refuses on a mismatch and
70+
prints the version to install. A full `python script/sync_boards.py` regenerates
71+
everything against your installed ESPHome and re-stamps that version, so it does
72+
not check; still run it from the venv so you don't commit catalog-wide changes
73+
from a different ESPHome.
74+
75+
### Curated vs generated vs imported boards
76+
77+
- **Curated** (most hand-written manifests): a `manifest.yaml` with no `source:`
78+
block. Edit freely; this is the normal case.
79+
- **Generated**: no manifest at all. The board comes from ESPHome's board tables
80+
on every sync, with a synthesized pin map and a generic image. To customise
81+
one (real pinout, photo, featured components), add a curated manifest at
82+
`boards/<esphome.board>/manifest.yaml`.
83+
- **Imported**: a manifest carrying a `source:` block (e.g.
84+
`type: esphome-devices`). It is owned by the importer and regenerated from
85+
upstream, so hand edits are overwritten; change the upstream source instead.
86+
587
## Adding a Board
688

789
Create a new subfolder in `boards/` with a `manifest.yaml`:
@@ -27,20 +109,28 @@ manufacturer: "Acme Corp"
27109

28110
# ESPHome configuration — maps directly to the ESPHome YAML platform block
29111
esphome:
30-
platform: esp32 # esp32, esp8266, rp2040, bk72xx, rtl87xx
112+
platform: esp32 # esp32, esp8266, rp2040, bk72xx, rtl87xx, ln882x, nrf52, host
31113
board: esp32-s3-devkitc-1 # PlatformIO board ID
32-
variant: esp32s3 # ESP32 chip variant (omit for esp8266/rp2040)
33-
framework: esp-idf # arduino or esp-idf (omit for platform default)
34-
flash_size: 8MB # 2MB, 4MB, 8MB, 16MB (omit for board default)
114+
variant: esp32s3 # ESP32 chip variant only (omit otherwise)
115+
framework: esp-idf # arduino, esp-idf, or zephyr (omit for platform default)
116+
117+
# Hardware specs (all optional)
118+
hardware:
119+
flash_size: 8MB # 2MB, 4MB, 8MB, 16MB
120+
ram_size: 327680 # bytes
121+
cpu_frequency: 240MHz
122+
connectivity: [wifi, bluetooth]
35123

36124
# Optional metadata
37-
tags: [esp32-s3, wifi, bluetooth, usb, rgb-led]
38-
docs_url: "https://example.com/docs"
125+
tags: [compact, usb-c, rgb-led] # only the enum values in board.schema.json
126+
docs_url: "https://esphome.io/components/esp32.html"
127+
product_url: "https://example.com/my-awesome-board"
39128
is_generic: false # true only for generic fallback boards
40129

41-
# Images — URLs or paths relative to this manifest (first = primary)
130+
# Images; URLs or paths relative to this manifest (first = primary).
131+
# Prefer a bundled local asset over a hotlinked vendor URL, which can rot.
42132
images:
43-
- "https://example.com/board.png"
133+
- "images/board-top.png"
44134
- "images/pinout.png"
45135

46136
# Pin definitions (see below)
@@ -49,6 +139,11 @@ pins:
49139
# ...
50140
```
51141

142+
`tags` accepts only the values enumerated in
143+
[`schemas/board.schema.json`](schemas/board.schema.json) (`compact`, `dev-kit`,
144+
`usb-c`, `rgb-led`, `poe`, ...); platform, variant, and connectivity live in
145+
their own fields, not in `tags`.
146+
52147
### Pin Definitions
53148

54149
The pin map is the most valuable part of a board definition. It enables the

esphome_device_builder/definitions/boards.index.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

script/sync_boards.py

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,27 @@
2323
remain the human-editable source of truth; this script is the only
2424
thing that writes the three artefacts.
2525
26+
Single-board mode (``BOARD_ID``) must run against the same ESPHome the
27+
rest of the committed catalog was generated against
28+
(``esphome_schema_version`` in ``components.index.json``), since it
29+
rebuilds the shared index from every board; it refuses on a mismatch. A
30+
full sync regenerates everything from the installed ESPHome and is
31+
internally consistent regardless, so it does not check.
32+
2633
Usage
2734
-----
2835
29-
python script/sync_boards.py
36+
python script/sync_boards.py # regenerate every board
37+
python script/sync_boards.py BOARD_ID # regenerate only one board
3038
"""
3139

3240
from __future__ import annotations
3341

42+
import argparse
3443
import importlib
3544
import logging
3645
import re
46+
import shutil
3747
import sys
3848
from dataclasses import replace
3949
from operator import attrgetter
@@ -811,6 +821,21 @@ def build_catalog() -> BoardCatalogResponse:
811821

812822
def main() -> int:
813823
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
824+
parser = argparse.ArgumentParser(
825+
description="Regenerate the board catalog JSON from the YAML manifests."
826+
)
827+
parser.add_argument(
828+
"board",
829+
nargs="?",
830+
help="Board id (the folder name under esphome_device_builder/definitions/boards/) "
831+
"to regenerate on its own. Omit to regenerate the whole catalog.",
832+
)
833+
args = parser.parse_args()
834+
835+
# Only single-board mode needs the match: it rebuilds the shared index from
836+
# every board, so a mismatched esphome drifts the others' index entries.
837+
if args.board:
838+
_require_matching_esphome()
814839

815840
# Abort the sync on the first bad manifest — partial output here
816841
# would silently ship a board-shaped hole to every install.
@@ -819,6 +844,10 @@ def main() -> int:
819844
# ``to_dict`` here already applies the omit_default Configs, so
820845
# body files and index entries both ship the stripped wire shape.
821846
full_payloads = [board.to_dict() for board in catalog.boards]
847+
848+
if args.board:
849+
return _emit_single_board(catalog.boards, full_payloads, args.board)
850+
822851
_emit_split_catalog(catalog.boards, full_payloads)
823852
_emit_featured_components_index(catalog.boards)
824853

@@ -832,6 +861,45 @@ def main() -> int:
832861
return 0
833862

834863

864+
def _emit_single_board(
865+
boards: list[BoardCatalogEntry], full_payloads: list[dict[str, Any]], board_id: str
866+
) -> int:
867+
"""
868+
Rewrite one board's body file, then refresh the index and featured map.
869+
870+
Only the target's ``board_bodies/<id>.json`` is rewritten; the index and
871+
featured-components files are full rebuilds whose other entries are
872+
byte-identical (the version guard rules out drift), so the diff stays
873+
scoped to the edited board. This assumes only *board_id* was edited:
874+
naming one board while another's manifest is also dirty rewrites the
875+
other's index entry but not its body, which the consistency test flags.
876+
"""
877+
idx = next((i for i, board in enumerate(boards) if board.id == board_id), None)
878+
if idx is None:
879+
raise SystemExit(
880+
f"sync_boards: no board with id {board_id!r}; "
881+
f"expected a folder name under {_DEFINITIONS_DIR / 'boards'}"
882+
)
883+
_emit_body_atomically(full_payloads[idx], board_id)
884+
_write_index(full_payloads)
885+
_emit_featured_components_index(boards)
886+
_LOGGER.info("Regenerated board_bodies/%s.json + refreshed index and featured map", board_id)
887+
return 0
888+
889+
890+
def _emit_body_atomically(payload: dict[str, Any], board_id: str) -> None:
891+
"""Write one body file via stage-then-replace so an interrupted write can't truncate it."""
892+
staging = _BODIES_DIR.parent / "board_bodies.single"
893+
prepare_next_bodies_dir(staging)
894+
try:
895+
emit_body_with_roundtrip(
896+
payload, board_id, staging, BoardCatalogEntry, log_label="Board", sort_keys=True
897+
)
898+
(staging / f"{board_id}.json").replace(_BODIES_DIR / f"{board_id}.json")
899+
finally:
900+
shutil.rmtree(staging, ignore_errors=True)
901+
902+
835903
def _emit_split_catalog(
836904
boards: list[BoardCatalogEntry], full_payloads: list[dict[str, Any]]
837905
) -> None:
@@ -853,23 +921,87 @@ def _emit_split_catalog(
853921
sort_keys=True,
854922
)
855923

856-
index_payload = {
857-
"boards": sorted(
858-
(_strip_body_fields(payload) for payload in full_payloads),
859-
key=lambda p: p["id"],
860-
),
861-
}
862924
swap_split_catalog_in(
863925
next_bodies=next_bodies,
864926
live_bodies=_BODIES_DIR,
865-
index_payload=index_payload,
927+
index_payload=_index_payload(full_payloads),
866928
live_index=_INDEX_FILE,
867929
index_cls=BoardCatalogIndex,
868930
index_entries_key="boards",
869931
sort_keys=True,
870932
)
871933

872934

935+
def _index_payload(full_payloads: list[dict[str, Any]]) -> dict[str, Any]:
936+
"""Build the slim index payload, stamped with the ESPHome it was generated from."""
937+
return {
938+
"esphome_version": _installed_esphome_version(),
939+
"boards": sorted(
940+
(_strip_body_fields(payload) for payload in full_payloads),
941+
key=lambda p: p["id"],
942+
),
943+
}
944+
945+
946+
# ESPHome betas/dev builds (``2026.7.0b1``, ``2026.7.0-dev``) share board tables
947+
# with their base release, so canonicalize to the base for both the stamp and
948+
# the guard or a beta would false-mismatch its own release.
949+
_ESPHOME_BASE_VERSION_RE = re.compile(r"^(\d+\.\d+\.\d+)")
950+
951+
952+
def _canonical_esphome_version(version: str) -> str:
953+
"""Drop a prerelease/dev suffix: ``2026.7.0b1`` -> ``2026.7.0``."""
954+
match = _ESPHOME_BASE_VERSION_RE.match(version)
955+
return match.group(1) if match else version
956+
957+
958+
def _installed_esphome_version() -> str:
959+
from esphome.const import __version__
960+
961+
return _canonical_esphome_version(__version__)
962+
963+
964+
def _write_index(full_payloads: list[dict[str, Any]]) -> None:
965+
"""Rewrite ``boards.index.json`` only, leaving the body files untouched."""
966+
index_payload = _index_payload(full_payloads)
967+
for entry in index_payload["boards"]:
968+
BoardCatalogIndex.from_dict(entry)
969+
next_index = _INDEX_FILE.with_suffix(".json.next")
970+
next_index.write_bytes(
971+
orjson.dumps(index_payload, option=orjson.OPT_SORT_KEYS | orjson.OPT_APPEND_NEWLINE)
972+
)
973+
next_index.replace(_INDEX_FILE)
974+
975+
976+
def _require_matching_esphome() -> None:
977+
"""Abort unless installed ESPHome matches the ``esphome_version`` boards.index.json was built with."""
978+
try:
979+
expected = orjson.loads(_INDEX_FILE.read_bytes())["esphome_version"]
980+
except (OSError, orjson.JSONDecodeError, KeyError):
981+
raise SystemExit(
982+
f"sync_boards: could not read esphome_version from {_INDEX_FILE}.\n"
983+
f"To fix, regenerate the whole catalog first: python script/sync_boards.py"
984+
) from None
985+
try:
986+
from esphome.const import __version__ as raw_installed
987+
except ImportError:
988+
raise SystemExit(
989+
f"sync_boards: ESPHome is not importable in this interpreter ({sys.executable}).\n"
990+
f"To fix, install it into the project venv and re-run:\n"
991+
f" uv pip install 'esphome=={expected}' # or: pip install 'esphome=={expected}'"
992+
) from None
993+
installed = _canonical_esphome_version(raw_installed)
994+
if installed != expected:
995+
raise SystemExit(
996+
f"sync_boards: single-board mode needs ESPHome {expected} (the version "
997+
f"boards.index.json was generated with), but {installed} is installed.\n"
998+
f"To fix, install the matching version into this venv and re-run:\n"
999+
f" uv pip install 'esphome=={expected}' # or: pip install 'esphome=={expected}'\n"
1000+
f"Or regenerate the whole catalog against your installed ESPHome instead:\n"
1001+
f" python script/sync_boards.py"
1002+
)
1003+
1004+
8731005
def _emit_featured_components_index(boards: list[BoardCatalogEntry]) -> None:
8741006
"""Write the aggregated ``{board_id: list[FeaturedComponent]}`` index.
8751007

0 commit comments

Comments
 (0)