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
3 changes: 3 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,14 @@ Board catalog dataclasses (`BoardCatalogIndex`, `BoardCatalogEntry`, `BoardHardw
| `components/get_categories` | `{board_id?}` | `[{id, name, count}]` | List categories with counts |
| `components/get_components` | `{query?, category?, exclude_category?, platform?, board_id?, offset?, limit?}` | `PagedComponentsResponse` | Search/list components |
| `components/get_component_bodies` | `{component_ids, platform?, board_id?}` | `{component_id: ComponentCatalogEntry}` | Hydrate one or many bodies; missing ids omitted |
| `components/get_pin_registry_modes` | _none_ | `{provider_key: [mode_flag, …]}` | Allowed long-form pin `mode` flags per external pin provider; empty when the artefact is missing |

`platform` filters to components compatible with the given target platform; components with an empty `supported_platforms` list are platform-agnostic and always included. `board_id` is a convenience — the boards catalog resolves it to a platform; `platform` wins when both are passed. The platform is also used to materialise each entry's `platform_defaults` into `default_value`.

`category` / `exclude_category` accept either a single category or a list. Use `exclude_category` for the regular catalog selector to hide entries that belong to the dedicated "Add core configuration" dialog.

**Pin registry modes.** A long-form pin on an external provider accepts only a subset of `mode` flags: an I2C expander like `pca9554` permits `input` / `output`, a shift register `sn74hc595` only `output`. `get_pin_registry_modes` returns the `{provider_key: [mode_flag, …]}` map (derived from ESPHome's `PIN_SCHEMA_REGISTRY` at sync time, excluding native target platforms which allow every flag) so the visual editor can hide the unsupported flag checkboxes. The key is the provider key that appears in the pin value (`pca9554`). Native pins (no provider key) and a missing artefact both fall back to showing every flag.

**Featured components.** The board catalog's `featured_components` are surfaced through this same API under the synthetic category `featured` and ID prefix `featured.<board_id>.<local_id>`. They are **only** returned when `category` explicitly includes `featured` and `board_id` is supplied — the regular catalog listing never mixes them in. `get_categories` adds a `featured` entry with the board's recommended-count when `board_id` is set. A featured `ComponentCatalogEntry` carries the board overrides baked into its `config_entries`: `default_value` reflects the preset, and the new `locked: bool` and `suggestions: list[ConfigPrimitive] | None` fields tell the frontend to disable the input or render a picker. `devices/add_component` recognises `featured.*` ids — the wire shape doesn't change, but the backend resolves the underlying component, validates user input against the locked/suggestion constraints, and merges presets before delegating to the regular merge logic.

### Automations
Expand Down
28 changes: 26 additions & 2 deletions esphome_device_builder/controllers/components/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import logging
from typing import TYPE_CHECKING, Any

from ...definitions import load_featured_components_index
from ...definitions import (
load_featured_components_index,
load_pin_registry_modes_index,
)
from ...helpers.api import api_command
from ...helpers.json import loads
from ...helpers.lazy_catalog import LazyBodyStore
Expand Down Expand Up @@ -51,6 +54,12 @@ def __init__(self, device_builder: DeviceBuilder | None = None) -> None:
# board's recommendations rather than the whole catalog.
self._featured_by_id: dict[str, _FeaturedRecord] = {}
self._featured_by_board: dict[str, list[str]] = {}
# ``{provider_key: [allowed_mode_flags]}`` — lets the frontend scope
# the long-form pin Mode checkboxes for external pin providers (an
# expander like pca9554 allows only input/output). Loaded in ``load()``;
# empty (or a native pin with no provider key) leaves the frontend
# showing every flag (the pre-scoping behaviour).
self._pin_registry_modes: dict[str, list[str]] = {}
self._body_store: LazyBodyStore[ComponentCatalogEntry] = LazyBodyStore(
load_one=_load_body_from_disk,
cache_maxsize=_BODY_CACHE_MAXSIZE,
Expand Down Expand Up @@ -86,10 +95,12 @@ def load(self) -> None:
]
self._by_id = {c.id: c for c in self._components}
self._build_featured_registry()
self._pin_registry_modes = load_pin_registry_modes_index()
_LOGGER.info(
"Component catalog loaded: %d components (slim index), %d featured",
"Component catalog loaded: %d components (slim index), %d featured, %d pin registries",
len(self._components),
len(self._featured_by_id),
len(self._pin_registry_modes),
)

@property
Expand Down Expand Up @@ -118,6 +129,19 @@ async def get_categories(
"""
return self._categories_for_board(board_id)

@api_command("components/get_pin_registry_modes")
async def get_pin_registry_modes(self, **kwargs: Any) -> dict[str, list[str]]:
"""
Return ``{provider_key: [allowed_mode_flags]}`` for pin Mode scoping.

The long-form pin Mode checkboxes an external pin provider supports are
a subset of the five flags (an I2C expander like ``pca9554`` allows only
input/output), keyed on the provider key that appears in the pin value.
Native pins (no provider key) and a missing artefact both fall back to
every flag.
"""
return self._pin_registry_modes

@api_command("components/get_integration_docs")
async def get_integration_docs(self, **kwargs: Any) -> dict[str, str]:
"""Return ``{integration_name: docs_url}`` for resolvable integrations.
Expand Down
38 changes: 38 additions & 0 deletions esphome_device_builder/definitions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
_BOARDS_INDEX_JSON = _DEFINITIONS_DIR / "boards.index.json"
_BOARDS_BODIES_DIR = _DEFINITIONS_DIR / "board_bodies"
_FEATURED_COMPONENTS_INDEX_JSON = _DEFINITIONS_DIR / "featured_components.index.json"
_PIN_REGISTRY_MODES_INDEX_JSON = _DEFINITIONS_DIR / "pin_registry_modes.index.json"

_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".svg", ".webp")
_GENERIC_DIR = _BOARDS_DIR / "_generic"
Expand Down Expand Up @@ -386,6 +387,43 @@ def load_featured_components_index() -> dict[str, list[FeaturedComponent]]:
}


def load_pin_registry_modes_index() -> dict[str, list[str]]:
"""Load the aggregated ``{registry_key: [allowed_modes]}`` map.

Read once at startup by the components controller so the frontend can
scope the long-form pin Mode checkboxes per registry. Missing / malformed
artefact yields an empty map; the frontend then shows every mode flag (the
pre-scoping behaviour).
"""
if not _PIN_REGISTRY_MODES_INDEX_JSON.exists():
_LOGGER.warning(
"pin_registry_modes.index.json missing — pin Mode flags won't be "
"scoped per registry. Run script/sync_components.py to generate it.",
)
return {}
try:
payload = orjson.loads(_PIN_REGISTRY_MODES_INDEX_JSON.read_bytes())
except Exception:
_LOGGER.exception(
"Failed to load pin_registry_modes.index.json — pin Mode flags won't be scoped."
)
return {}
if not isinstance(payload, dict):
_LOGGER.warning(
"pin_registry_modes.index.json is not a mapping — ignoring; pin Mode "
"flags won't be scoped."
)
return {}
# Tolerate a malformed artefact: drop any entry whose value isn't a list,
# keep only string flags, so a partial / hand-mangled file degrades to
# "show every flag" rather than crashing startup.
return {
str(key): [str(m) for m in modes if isinstance(m, str)]
for key, modes in payload.items()
if isinstance(modes, list)
}


def load_board_catalog() -> BoardCatalogResponse:
"""Reassemble the full board catalog from the split artefacts.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"ch422g":["input","open_drain","output"],"ch423":["input","open_drain","output"],"max6956":["input","output","pullup"],"mcp23016":["input","output"],"mcp23xxx":["input","output","pullup"],"mpr121":["input","output"],"pca6416a":["input","output","pullup"],"pca9554":["input","output"],"pcf8574":["input","output"],"pi4ioe5v6408":["input","output","pulldown","pullup"],"sn74hc165":["input"],"sn74hc595":["output"],"sx1509":["input","open_drain","output","pulldown","pullup"],"tca9555":["input","output"],"wk2168_i2c":["input","output"],"wk2168_spi":["input","output"],"wk2212_i2c":["input","output"],"wk2212_spi":["input","output"],"xl9535":["input","output"]}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ esphome_device_builder = [
"definitions/boards.index.json",
"definitions/board_bodies/*.json",
"definitions/featured_components.index.json",
"definitions/pin_registry_modes.index.json",
"definitions/components.index.json",
"definitions/components/*.json",
"definitions/automations.index.json",
Expand Down
126 changes: 126 additions & 0 deletions script/sync_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from __future__ import annotations

import argparse
import contextlib
import copy
import inspect
import json
Expand All @@ -50,6 +51,7 @@
from pathlib import Path
from typing import Any, NamedTuple

import orjson
import voluptuous as vol

# ---------------------------------------------------------------------------
Expand All @@ -64,6 +66,7 @@
_OUTPUT_BODIES_DIR = _DEFINITIONS_DIR / "components"
_AUTOMATIONS_INDEX_FILE = _DEFINITIONS_DIR / "automations.index.json"
_AUTOMATIONS_BODIES_DIR = _DEFINITIONS_DIR / "automations"
_PIN_REGISTRY_MODES_INDEX_FILE = _DEFINITIONS_DIR / "pin_registry_modes.index.json"
_CACHE_ROOT = _REPO_ROOT / ".cache"

# Fields stripped from index entries — they belong on the per-id body
Expand Down Expand Up @@ -833,6 +836,29 @@ def main() -> int:
len(automations["light_effects"]),
)
_emit_split_automations_catalog(automations, version)

# Per-registry pin mode flags: the long-form Mode checkboxes a given pin
# supports depend on its registry (an I2C expander like pca9554 allows
# only input/output), which is value-keyed, not field-keyed, so it ships
# as one global map the frontend consults at render time. Skipped on a
# ``--limit-component`` debug run, whose partial registry would clobber
# the committed artifact.
if not args.limit_component:
stems = {cid.split(".")[-1] for cid in component_ids}
registry_modes = _build_pin_registry_modes(stems)
if registry_modes:
_emit_pin_registry_modes_index(registry_modes)
_LOGGER.info(
"Wrote pin registry modes: %d registries -> %s",
len(registry_modes),
_PIN_REGISTRY_MODES_INDEX_FILE,
)
else:
_LOGGER.warning(
"Derived no pin registry modes — PIN_SCHEMA_REGISTRY empty or "
"esphome not importable; %s left untouched",
_PIN_REGISTRY_MODES_INDEX_FILE,
)
return 0


Expand Down Expand Up @@ -2388,6 +2414,106 @@ def _pin_long_form_extras(schema_dir: Path) -> tuple[dict, ...]:
return tuple(extras)


def _pin_schema_mode_mapping(node: Any) -> dict | None:
"""Return the underlying mapping of a pin-schema node, or ``None``.

Unwraps voluptuous ``All`` wrappers (the native-platform pin schema is
``All(Schema(...), validate, finalize)``) and ``Schema`` objects down to
the first ``dict`` so a flag mapping can be read off it regardless of how
deeply ESPHome nests it.
"""
if isinstance(node, dict):
return node
inner = getattr(node, "schema", None)
if isinstance(inner, dict):
return inner
for sub in getattr(node, "validators", ()) or ():
found = _pin_schema_mode_mapping(sub)
if found is not None:
return found
return None


def _pin_registry_allowed_modes(schema: Any) -> list[str] | None:
"""Return the sorted ``mode`` flag keys a pin-registry schema permits.

``gpio_base_schema`` builds the ``mode`` value as a mapping of one
``Optional(flag): boolean`` per allowed flag; this walks to that mapping
and reads the flag names. ``None`` when the schema exposes no parseable
``mode`` mapping (so the caller drops the registry rather than emitting a
bogus empty allow-list).
"""
top = _pin_schema_mode_mapping(schema)
if top is None:
return None
for marker, value in top.items():
if str(getattr(marker, "schema", marker)) != "mode":
continue
flags = _pin_schema_mode_mapping(value)
if flags is None:
return None
return sorted(str(getattr(m, "schema", m)) for m in flags)
return None


def _build_pin_registry_modes(component_stems: Iterable[str]) -> dict[str, list[str]]:
"""Map each external pin provider to the ``mode`` flags it allows.

Pin providers register into ESPHome's ``PIN_SCHEMA_REGISTRY`` on import,
so every component is imported first to populate it, then each registered
schema is introspected for its allowed ``mode`` flags. Native target
platforms (the ``Platform``-keyed entries) allow every checkbox flag and
are skipped — only external providers (``pca9554``, ``sn74hc595``, …),
keyed on the provider key that appears in a pin value, restrict the set.
Returns ``{}`` when esphome isn't importable.
"""
loader = _get_esphome_loader()
if loader is None:
return {}
try:
from esphome import pins
from esphome.const import Platform
except Exception:
return {}
for stem in component_stems:
# Best-effort: most stems aren't pin providers and many don't import
# standalone; we only need the ones that register a pin schema.
with contextlib.suppress(Exception):
loader.get_component(stem)
out: dict[str, list[str]] = {}
platform_names = {p.value for p in Platform}
registry = pins.PIN_SCHEMA_REGISTRY
for key in registry:
# Native target platforms allow every checkbox flag, so scoping a native
# pin would be a no-op; emit only the external providers (pca9554,
# sn74hc595, …) that actually restrict, matched against the provider key
# in the pin value. Natives register under both the ``Platform`` enum
# and bare platform-name strings (rp2040, bk72xx, …), so filter on the
# name rather than the key type.
if str(key) in platform_names:
continue
entry = registry[key]
schema = entry[1] if isinstance(entry, (tuple, list)) and len(entry) > 1 else entry
modes = _pin_registry_allowed_modes(schema)
if modes:
out[str(key)] = modes
return out


def _emit_pin_registry_modes_index(registry_modes: dict[str, list[str]]) -> None:
"""Write the aggregated ``{registry_key: [allowed_modes]}`` map.

The components controller reads this once at startup so the frontend can
scope the long-form pin Mode checkboxes per registry (an I2C expander like
``pca9554`` allows only ``input`` / ``output``). Atomic temp-then-replace.
"""
next_path = _PIN_REGISTRY_MODES_INDEX_FILE.with_suffix(".json.next")
next_path.write_bytes(
orjson.dumps(registry_modes, option=orjson.OPT_SORT_KEYS | orjson.OPT_APPEND_NEWLINE)
)
next_path.replace(_PIN_REGISTRY_MODES_INDEX_FILE)


def _synthesise_long_form_extra(
*,
key: str,
Expand Down
29 changes: 29 additions & 0 deletions tests/controllers/test_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,3 +874,32 @@ def test_materialise_entry_keeps_default_when_platform_absent() -> None:

assert resolved.default_value == 9600
assert resolved.platform_defaults is None


def test_get_pin_registry_modes_returns_cached_map() -> None:
"""The endpoint returns the loaded ``{registry: [modes]}`` map verbatim."""
cat = ComponentCatalog()
cat._pin_registry_modes = {"pca9554": ["input", "output"]}

assert asyncio.run(cat.get_pin_registry_modes()) == {"pca9554": ["input", "output"]}


def test_load_populates_pin_registry_modes(tmp_path: Path) -> None:
"""``load()`` caches the per-registry mode map from the loader."""
index_path = tmp_path / "components.index.json"
index_path.write_text(json.dumps({"components": []}))
cat = ComponentCatalog()
with (
patch(
"esphome_device_builder.controllers.components.controller._COMPONENTS_INDEX_JSON",
index_path,
),
patch(
"esphome_device_builder.controllers.components.controller."
"load_pin_registry_modes_index",
return_value={"esp32": ["input", "output", "pullup"]},
),
):
cat.load()

assert cat._pin_registry_modes == {"esp32": ["input", "output", "pullup"]}
53 changes: 53 additions & 0 deletions tests/test_definitions_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
load_board_catalog,
load_board_index,
load_featured_components_index,
load_pin_registry_modes_index,
)
from esphome_device_builder.models import (
BoardCatalogIndex,
Expand Down Expand Up @@ -251,6 +252,58 @@ def test_load_board_index_handles_corrupt_json(
)


def test_load_pin_registry_modes_warns_when_json_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Missing artefact returns an empty map (frontend then shows every flag)."""
monkeypatch.setattr(defs, "_PIN_REGISTRY_MODES_INDEX_JSON", tmp_path / "missing.index.json")

with caplog.at_level(logging.WARNING):
result = load_pin_registry_modes_index()

assert result == {}
assert any("pin_registry_modes.index.json" in rec.getMessage() for rec in caplog.records)


def test_load_pin_registry_modes_reads_json(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``load_pin_registry_modes_index`` deserialises the registry -> modes map."""
json_path = tmp_path / "pin_registry_modes.index.json"
json_path.write_bytes(orjson.dumps({"pca9554": ["input", "output"]}))
monkeypatch.setattr(defs, "_PIN_REGISTRY_MODES_INDEX_JSON", json_path)

assert load_pin_registry_modes_index() == {"pca9554": ["input", "output"]}


def test_load_pin_registry_modes_handles_corrupt_json(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Malformed artefact returns an empty map instead of crashing startup."""
json_path = tmp_path / "pin_registry_modes.index.json"
json_path.write_bytes(b"{not valid json")
monkeypatch.setattr(defs, "_PIN_REGISTRY_MODES_INDEX_JSON", json_path)

with caplog.at_level(logging.ERROR):
result = load_pin_registry_modes_index()

assert result == {}


def test_load_pin_registry_modes_tolerates_unexpected_shapes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A non-mapping payload yields {}; entries with non-list values are dropped."""
json_path = tmp_path / "pin_registry_modes.index.json"
monkeypatch.setattr(defs, "_PIN_REGISTRY_MODES_INDEX_JSON", json_path)

json_path.write_bytes(orjson.dumps([1, 2, 3]))
assert load_pin_registry_modes_index() == {}

json_path.write_bytes(orjson.dumps({"pca9554": ["input", 5], "bad": "nope"}))
assert load_pin_registry_modes_index() == {"pca9554": ["input"]}


def test_load_board_body_refuses_traversal_id(caplog: pytest.LogCaptureFixture) -> None:
"""A traversal-shaped id is refused with a warning and ``None`` return."""
with caplog.at_level(logging.WARNING):
Expand Down
Loading
Loading