diff --git a/docs/API.md b/docs/API.md index 1fd69aeaf..61b0b9c66 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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..`. 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 diff --git a/esphome_device_builder/controllers/components/controller.py b/esphome_device_builder/controllers/components/controller.py index 02fb840c2..3483d578c 100644 --- a/esphome_device_builder/controllers/components/controller.py +++ b/esphome_device_builder/controllers/components/controller.py @@ -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 @@ -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, @@ -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 @@ -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. diff --git a/esphome_device_builder/definitions/__init__.py b/esphome_device_builder/definitions/__init__.py index 9db90a1de..fb70f0f7c 100644 --- a/esphome_device_builder/definitions/__init__.py +++ b/esphome_device_builder/definitions/__init__.py @@ -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" @@ -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. diff --git a/esphome_device_builder/definitions/pin_registry_modes.index.json b/esphome_device_builder/definitions/pin_registry_modes.index.json new file mode 100644 index 000000000..1e6d38444 --- /dev/null +++ b/esphome_device_builder/definitions/pin_registry_modes.index.json @@ -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"]} diff --git a/pyproject.toml b/pyproject.toml index 8e5b361c1..69ab192e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/script/sync_components.py b/script/sync_components.py index d18f0ea9e..6b8d50ff0 100755 --- a/script/sync_components.py +++ b/script/sync_components.py @@ -33,6 +33,7 @@ from __future__ import annotations import argparse +import contextlib import copy import inspect import json @@ -50,6 +51,7 @@ from pathlib import Path from typing import Any, NamedTuple +import orjson import voluptuous as vol # --------------------------------------------------------------------------- @@ -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 @@ -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 @@ -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, diff --git a/tests/controllers/test_components.py b/tests/controllers/test_components.py index dbd62b472..2246f6b33 100644 --- a/tests/controllers/test_components.py +++ b/tests/controllers/test_components.py @@ -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"]} diff --git a/tests/test_definitions_loader.py b/tests/test_definitions_loader.py index d68bbcded..a3664c230 100644 --- a/tests/test_definitions_loader.py +++ b/tests/test_definitions_loader.py @@ -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, @@ -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): diff --git a/tests/test_sync_components_pin_registry_modes.py b/tests/test_sync_components_pin_registry_modes.py new file mode 100644 index 000000000..2b41331c7 --- /dev/null +++ b/tests/test_sync_components_pin_registry_modes.py @@ -0,0 +1,86 @@ +""" +Tests for the per-provider pin ``mode`` flag derivation. + +The long-form pin Mode checkboxes a value supports depend on its pin +provider: an I2C expander like ``pca9554`` allows only ``input`` / +``output``, while a native pin allows all five flags. The sync +introspects ESPHome's live ``PIN_SCHEMA_REGISTRY`` to emit a global +``{provider_key: [allowed_modes]}`` map (excluding native target +platforms) the frontend scopes against. + +These walk the installed esphome package, so they pin the contract +against ESPHome's real pin schemas rather than a hand-maintained table. +""" + +from __future__ import annotations + +import voluptuous as vol +from esphome.const import Platform + +from script.sync_components import ( # type: ignore[import-not-found] + _build_pin_registry_modes, + _pin_registry_allowed_modes, + _pin_schema_mode_mapping, +) + + +def test_pin_schema_mode_mapping_unwraps_schema_and_all() -> None: + mapping = {vol.Optional("input"): bool} + assert _pin_schema_mode_mapping(mapping) is mapping + assert _pin_schema_mode_mapping(vol.Schema(mapping)) == mapping + assert _pin_schema_mode_mapping(vol.All(vol.Schema(mapping), str)) == mapping + + +def test_pin_schema_mode_mapping_returns_none_for_scalar() -> None: + assert _pin_schema_mode_mapping("nope") is None + assert _pin_schema_mode_mapping(123) is None + + +def test_pin_registry_allowed_modes_reads_mode_flag_keys() -> None: + schema = vol.Schema( + { + vol.Required("number"): int, + vol.Optional("mode"): vol.All( + {vol.Optional("input"): bool, vol.Optional("output"): bool}, + lambda v: v, + ), + } + ) + assert _pin_registry_allowed_modes(schema) == ["input", "output"] + + +def test_pin_registry_allowed_modes_none_without_mode_key() -> None: + assert _pin_registry_allowed_modes(vol.Schema({vol.Required("number"): int})) is None + + +def test_build_derives_external_provider_modes() -> None: + modes = _build_pin_registry_modes( + ["esp32", "pca9554", "pcf8574", "mcp23017", "sx1509", "sn74hc595", "sn74hc165"] + ) + # Expanders are direction-only. + assert modes["pca9554"] == ["input", "output"] + assert modes["pcf8574"] == ["input", "output"] + # mcp23017 registers under the shared ``mcp23xxx`` key and adds pullup. + assert "pullup" in modes["mcp23xxx"] + assert "pulldown" not in modes["mcp23xxx"] + # Shift registers are single-direction. + assert modes["sn74hc595"] == ["output"] + assert modes["sn74hc165"] == ["input"] + + +def test_build_excludes_native_target_platforms() -> None: + """Native platforms allow every flag, so only external providers are emitted. + + Scoping a native pin would be a no-op; the filter drops them whether the + platform registers under the ``Platform`` enum (esp32) or a bare string + (rp2040 / bk72xx). + """ + modes = _build_pin_registry_modes(["esp32", "esp8266", "rp2040", "host", "pca9554"]) + platform_names = {p.value for p in Platform} + assert not platform_names & modes.keys() + assert "pca9554" in modes + + +def test_build_returns_sorted_flag_lists() -> None: + modes = _build_pin_registry_modes(["sx1509"]) + assert modes["sx1509"] == sorted(modes["sx1509"])