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
10 changes: 10 additions & 0 deletions esphome_device_builder/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,13 @@ def is_secrets_file(configuration: str | Path) -> bool:
# ``source`` block. Shared so the importer (writer) and the loader (reader)
# can't drift.
DEVICE_IMPORT_SOURCE_TYPE = "esphome-devices"

# Generated catalog categories for ESPHome's buses (the ``_CATEGORY_OVERRIDES``
# bus entries in script/sync_components.py). Mapping-style buses (i2c/spi/uart/
# modbus) collapse to ``"bus"``; platform-style buses (one_wire/canbus) keep
# their domain name as the category because they are ``IS_PLATFORM_COMPONENT``
# and have no top-level component, so the dep name itself equals the category.
# Shared so the importer (script/sync_esphome_devices.py, which lifts buses) and
# the validator (script/validate_definitions.py, which checks they were lifted)
# can't drift. Stdlib-only home, so neither script pulls in ``esphome``.
BUS_CATEGORIES: frozenset[str] = frozenset({"bus", "one_wire", "canbus"})
12 changes: 3 additions & 9 deletions script/sync_esphome_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

from esphome_device_builder.constants import ( # noqa: E402
BOARD_PIN_KEYS,
BUS_CATEGORIES,
DEVICE_IMPORT_SOURCE_TYPE,
)
from esphome_device_builder.helpers.pin_gpio import parse_board_gpio # noqa: E402
Expand Down Expand Up @@ -177,13 +178,6 @@
}
)

# Generated catalog categories for ESPHome's buses (script/sync_components.py
# ``_CATEGORY_OVERRIDES``). Mapping-style buses (i2c/spi/uart/modbus) collapse
# to ``"bus"``; platform-style buses (one_wire/canbus) keep their domain name as
# the category because they are ``IS_PLATFORM_COMPONENT`` and have no top-level
# component, so the dep name itself equals the category.
_BUS_CATEGORIES: frozenset[str] = frozenset({"bus", "one_wire", "canbus"})

# Tag mapping from frontmatter ``type:`` to BoardTag values. Most
# upstream types map to no tag because our enum is about *hardware*
# features (relay, display, ...) while the upstream types are about
Expand Down Expand Up @@ -1878,7 +1872,7 @@ def _find_consumer_block(config: dict[str, Any], entry: dict[str, Any]) -> dict[

def _is_bus_category(component: dict[str, Any]) -> bool:
"""Whether a resolved catalog component is a bus (mapping- or platform-style)."""
return component.get("category") in _BUS_CATEGORIES
return component.get("category") in BUS_CATEGORIES


def _is_bus_dep(dep: str, components_index: dict[str, dict[str, Any]]) -> bool:
Expand All @@ -1893,7 +1887,7 @@ def _is_bus_dep(dep: str, components_index: dict[str, dict[str, Any]]) -> bool:
component = components_index.get(dep)
if component is not None:
return _is_bus_category(component)
return dep in _BUS_CATEGORIES
return dep in BUS_CATEGORIES


def _collect_bus_dep_refs(
Expand Down
95 changes: 94 additions & 1 deletion script/validate_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
sys.path.insert(0, str(_REPO_ROOT))

# Imported from the stdlib-only constants module so this script stays light.
from esphome_device_builder.constants import BOARD_PIN_KEYS # noqa: E402
from esphome_device_builder.constants import BOARD_PIN_KEYS, BUS_CATEGORIES # noqa: E402

DEFINITIONS_DIR = _REPO_ROOT / "esphome_device_builder" / "definitions"
SCHEMAS_DIR = DEFINITIONS_DIR / "schemas"
Expand All @@ -57,6 +57,22 @@
# as a valid identifier and what the sync script's auto-id format produces.
_FEATURED_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")

# ``(board_id, bus)`` pairs whose source can't yet express a lift-able bus, so a
# featured leaf's dependency on that bus is knowingly unsatisfied (the full-setup
# config won't compile) pending a source-level fix. Keyed on the specific bus, not
# the whole board, so a *different* unsatisfied bus on the same board still fails.
# This is an allow list, not a silent skip: removing a pair must be paired with a
# fix in script/sync_esphome_devices.py.
_UNSATISFIED_BUS_ALLOW_LIST = frozenset(
{
("kincony_ag8", "uart"), # switch.uart: source has no top-level uart: block
("kincony_kc868_aio", "one_wire"), # dallas_temp: one_wire: block omits platform:
("kincony_kc868_e8t", "uart"), # sensor.bl0939: source has no top-level uart: block
("kincony_kc868_uair", "one_wire"), # dallas_temp: one_wire: block omits platform:
("kincony_mb", "i2c"), # sensor.ina226: source has no top-level i2c: block
}
)

# Pin features the board manifest can declare (mirrors the JSON Schema enum
# in board.schema.json). Components.json sometimes carries pin_features
# values like "input" / "output" that the board side doesn't model — we
Expand Down Expand Up @@ -253,6 +269,9 @@ def _validate_featured( # noqa: C901
)

errors.extend(_validate_default_components(board_id, defaults, seen_fc_ids, components_index))
errors.extend(
_validate_featured_dependencies(board_id, featured, components_index, is_imported, defaults)
)
return errors


Expand Down Expand Up @@ -286,6 +305,80 @@ def _validate_default_components(
return out


def _is_bus_dep(dep: str, components_index: dict) -> bool:
"""Whether *dep* names a bus, mapping- (top-level, category bus) or platform-style."""
component = components_index.get(dep)
if component is not None:
return component.get("category") in BUS_CATEGORIES
return dep in BUS_CATEGORIES


def _ref_ids(entries: list) -> set[str]:
"""Component ids/refs named by a featured or default-components list."""
ids: set[str] = set()
for entry in entries:
if isinstance(entry, str):
ids.add(entry)
elif isinstance(entry, dict) and isinstance(entry.get("component_id"), str):
ids.add(entry["component_id"])
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
ids.add(entry["id"])
return ids


def _validate_featured_dependencies(
board_id: str,
featured: list,
components_index: dict | None,
is_imported: bool,
defaults: list | None = None,
) -> list[str]:
"""
Flag a featured leaf whose bus dependency no component on the board provides.

An imported board ships its featured components as a complete config, so a
leaf binding a bus (i2c/spi/uart/modbus/one_wire/canbus) by catalog dependency
won't compile unless the bus is provided too (lifted by the sync script into
featured or default components). Only imported boards are checked; a
``(board, bus)`` pair in the allow list — a known source-level gap — is waived
while any *other* unsatisfied bus on the same board still fails.
"""
if not is_imported or components_index is None:
return []
present = _ref_ids(featured) | _ref_ids(defaults or [])
present_domains = {cid.split(".")[0] for cid in present}
out: list[str] = []
for idx, entry in enumerate(featured):
if not isinstance(entry, dict):
continue
cid = entry.get("component_id")
# Only platform leaves (``<domain>.<platform>`` — sensors, displays,
# touchscreens) bind a bus unconditionally; their bus is their sole
# connection. Bare top-level components are buses themselves (no bus dep)
# or dual-mode hubs whose bus dependency is conditional (``sn74hc595``
# bit-bangs over GPIO *or* runs on spi), so the catalog ``dependencies``
# over-declares the bus and checking them here would false-positive.
if not isinstance(cid, str) or "." not in cid:
continue
component = components_index.get(cid)
if not component:
continue
for dep in component.get("dependencies") or []:
if not isinstance(dep, str) or not _is_bus_dep(dep, components_index):
continue
if dep in present or dep in present_domains:
continue
if (board_id, dep) in _UNSATISFIED_BUS_ALLOW_LIST:
continue
out.append(
f"{board_id}.featured_components[{idx}]({entry.get('id')}): depends on bus "
f"'{dep}' but no featured component provides it; the full-setup config won't "
f"compile. Lift the bus in script/sync_esphome_devices.py, or add "
f"({board_id!r}, {dep!r}) to _UNSATISFIED_BUS_ALLOW_LIST with the source reason."
)
return out


def _validate_featured_component( # noqa: C901
board_id: str,
idx: int,
Expand Down
73 changes: 73 additions & 0 deletions tests/test_validate_featured.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@
from script.validate_definitions import ( # type: ignore[import-not-found]
_build_components_index,
_validate_featured,
_validate_featured_dependencies,
_validate_field_preset,
)


def _leaf(component_id: str, fid: str) -> dict:
"""Return a minimal finalized featured-component entry."""
return {"id": fid, "component_id": component_id, "fields": {"id": {"value": fid}}}


# Co-locate with the rest of the catalog-heavy suite so we don't burn a
# second xdist worker re-reading ``components.index.json``.
pytestmark = pytest.mark.xdist_group("catalog")
Expand Down Expand Up @@ -417,3 +424,69 @@ def test_default_components_object_form_missing_id_flagged(_index: dict | None)
_index,
)
assert any("missing 'id'" in e for e in errors)


def test_featured_dep_unsatisfied_bus_is_flagged(_index: dict | None) -> None:
"""An imported board featuring an i2c sensor with no i2c bus is flagged."""
errors = _validate_featured_dependencies("demo", [_leaf("sensor.sht3xd", "t")], _index, True)
assert any("depends on bus 'i2c'" in e for e in errors)


def test_featured_dep_satisfied_by_featured_bus(_index: dict | None) -> None:
"""The board passes once the i2c bus is also featured."""
featured = [_leaf("i2c", "bus"), _leaf("sensor.sht3xd", "t")]
assert _validate_featured_dependencies("demo", featured, _index, True) == []

Comment on lines +435 to +439

def test_featured_dep_platform_bus_satisfied_by_domain(_index: dict | None) -> None:
"""A one_wire dep is satisfied by a featured one_wire.gpio (domain match)."""
featured = [_leaf("one_wire.gpio", "ow"), _leaf("sensor.dallas_temp", "t")]
assert _validate_featured_dependencies("demo", featured, _index, True) == []


def test_featured_dep_not_checked_for_non_imported(_index: dict | None) -> None:
"""Curated (non-imported) boards are exempt; their featured set is à la carte."""
assert (
_validate_featured_dependencies("demo", [_leaf("sensor.sht3xd", "t")], _index, False) == []
)


def test_featured_dep_allowlisted_board_is_skipped(_index: dict | None) -> None:
"""An allow-listed board's known-unsatisfied dep does not fail validation."""
assert (
_validate_featured_dependencies("kincony_mb", [_leaf("sensor.sht3xd", "t")], _index, True)
== []
)


def test_featured_dep_allowlist_is_per_bus_not_per_board(_index: dict | None) -> None:
"""An allow-listed board still fails for a *different* unsatisfied bus."""
# kincony_mb is waived for i2c only; an spi leaf with no spi bus must still fail.
errors = _validate_featured_dependencies(
"kincony_mb", [_leaf("display.mipi_spi", "d")], _index, True
)
assert any("depends on bus 'spi'" in e for e in errors)


def test_featured_dep_satisfied_by_default_component(_index: dict | None) -> None:
"""A bus provided through default_components counts as present."""
errors = _validate_featured_dependencies(
"demo", [_leaf("sensor.sht3xd", "t")], _index, True, defaults=["i2c"]
)
assert errors == []


def test_featured_dep_dual_mode_top_level_hub_not_flagged(_index: dict | None) -> None:
"""A bare dual-mode hub (sn74hc595 bit-banged over GPIO) is not flagged for spi."""
# sn74hc595 declares dependencies: ["spi"] but here drives GPIO pins, so the
# bus dep is conditional; the platform-leaf scoping intentionally skips it
# rather than false-positive on every dual-mode hub.
featured = [{"id": "sr", "component_id": "sn74hc595", "fields": {"data_pin": {"value": 1}}}]
assert _validate_featured_dependencies("demo", featured, _index, True) == []


def test_featured_dep_non_string_component_id_does_not_crash(_index: dict | None) -> None:
"""A malformed non-string component_id is skipped (no unhashable-type crash)."""
featured = [{"id": "x", "component_id": ["bad"]}, _leaf("sensor.sht3xd", "t")]
errors = _validate_featured_dependencies("demo", featured, _index, True)
assert any("depends on bus 'i2c'" in e for e in errors)
Loading