Skip to content

Commit 0711a15

Browse files
committed
Introspect per-model requiredness for model-driven displays
The schema bundle dumps one representative model's schema for the mipi model_schema_extractor displays, so epaper_spi shipped cs_pin, dc_pin and dimensions unconditionally required and the add form refused models that predefine them. Resolve the live schema per model in the sync and emit requiredness gated on model, scrub defaults that vary by model, and fail the sync on unrecognized model-driven schemas.
1 parent ecdcac1 commit 0711a15

7 files changed

Lines changed: 568 additions & 24 deletions

File tree

esphome_device_builder/definitions/components/display.epaper_spi.json

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

esphome_device_builder/definitions/components/display.mipi_dsi.json

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

esphome_device_builder/definitions/components/display.mipi_rgb.json

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

esphome_device_builder/definitions/components/display.mipi_spi.json

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

script/sync_components.py

Lines changed: 225 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from functools import cache
5656
from io import BytesIO
5757
from pathlib import Path
58-
from types import FunctionType, SimpleNamespace
58+
from types import CodeType, FunctionType, SimpleNamespace
5959
from typing import Any, Literal, NamedTuple
6060

6161
import orjson
@@ -681,16 +681,6 @@ def _require_component_categories(domains: frozenset[str]) -> None:
681681
"allow_custom_value": True,
682682
"options": [{"label": "0 (disable logging)", "value": "0"}, *_BAUD_RATE_OPTIONS],
683683
},
684-
# ``display.mipi_spi.dc_pin`` is conditionally required — DC exists only on
685-
# single/octal (ILI/ST) panels, never on quad AMOLED — enforced by a runtime
686-
# validator, not the schema. esphome declares it ``cv.Optional`` via
687-
# ``model.option`` and 2026.7.0's dumped schema reports it optional; 2026.6.4
688-
# (the pinned version) dumps it required, so the frontend seeds a bogus
689-
# ``dc_pin`` on quad displays. Forward-port the 2026.7.0 behaviour here; remove
690-
# this override when the esphome dependency is bumped to >= 2026.7.0.
691-
("display.mipi_spi", "dc_pin"): {
692-
"required": False,
693-
},
694684
}
695685

696686
# Field paths whose live ``vol.Range`` max is derived from the machine
@@ -982,7 +972,7 @@ def main() -> int:
982972
_fail_on_unhandled_renames()
983973
if not args.limit_component:
984974
_fail_on_missing_channel_colors_rules(catalog)
985-
_fail_on_unhandled_repr_keys()
975+
_fail_on_unhandled_schema_shapes()
986976

987977
# Collected (and guarded) before any emit so the abort below leaves
988978
# the tree untouched; ``build_catalog``'s import sweep has already
@@ -3103,6 +3093,9 @@ def build_component_entry(
31033093
_apply_refined_types(config_entries, refined_types)
31043094
_apply_field_ranges(config_entries, field_ranges, component_id)
31053095
_apply_component_gates(config_entries, introspection.get("component_gates") or {})
3096+
_apply_model_variance(
3097+
config_entries, (introspection.get("model_variance") or {}).get(domain), component_id
3098+
)
31063099
_apply_typed_defaults(config_entries, introspection.get("typed_defaults") or {})
31073100
_apply_inclusive_groups(config_entries, introspection.get("inclusive_groups") or {})
31083101
_apply_list_fields(config_entries, introspection.get("list_fields") or {})
@@ -5453,6 +5446,12 @@ def merge_from_platforms(
54535446
manifest, platform_manifests_by_domain
54545447
)
54555448

5449+
# Domain-keyed like the bleed keys: a model-driven schema is a
5450+
# per-manifest property, never merged across domains.
5451+
model_variance = _collect_model_variance_by_domain(
5452+
manifest, platform_manifests_by_domain, component_id
5453+
)
5454+
54565455
return {
54575456
"multi_conf": manifest_multi_conf,
54585457
"is_target_platform": bool(getattr(manifest, "is_target_platform", False)),
@@ -5468,6 +5467,7 @@ def merge_from_platforms(
54685467
"list_fields": list_fields,
54695468
"registry_members": registry_members,
54705469
"typed_defaults": typed_defaults,
5470+
"model_variance": model_variance,
54715471
"auto_load": auto_load,
54725472
}
54735473

@@ -6755,6 +6755,113 @@ def visit(key: Any, _key_name: str, val: Any, path: tuple[str, ...]) -> None:
67556755
return out
67566756

67576757

6758+
class ModelField(NamedTuple):
6759+
"""One field's shape under one model: requiredness + live default."""
6760+
6761+
required: bool
6762+
default: Any
6763+
6764+
6765+
class ModelVariance(NamedTuple):
6766+
"""Per-model field facts introspected from a model-driven CONFIG_SCHEMA."""
6767+
6768+
models: tuple[str, ...]
6769+
fields: dict[str, dict[str, ModelField]]
6770+
6771+
6772+
#: Component ids whose ``CONFIG_SCHEMA`` closure looks model-driven but isn't
6773+
#: the mipi extractor ``_collect_model_variance`` introspects.
6774+
_UNHANDLED_MODEL_DRIVEN: set[str] = set()
6775+
6776+
6777+
@cache
6778+
def _mipi_model_wrapper_code() -> CodeType | None:
6779+
"""Code object of the ``model_schema_extractor`` wrapper closure, None without esphome."""
6780+
try:
6781+
mipi = importlib.import_module("esphome.components.mipi")
6782+
extractor = mipi.model_schema_extractor
6783+
except Exception:
6784+
return None
6785+
try:
6786+
decorate = next(
6787+
c
6788+
for c in extractor.__code__.co_consts
6789+
if isinstance(c, CodeType) and c.co_name == "decorate"
6790+
)
6791+
return next(
6792+
c for c in decorate.co_consts if isinstance(c, CodeType) and c.co_name == "wrapper"
6793+
)
6794+
except StopIteration:
6795+
raise SystemExit(
6796+
"esphome.components.mipi.model_schema_extractor no longer builds the "
6797+
"decorate/wrapper closures _collect_model_variance anchors on; update "
6798+
"_mipi_model_wrapper_code for the new shape."
6799+
) from None
6800+
6801+
6802+
def _collect_model_variance(manifest: Any, component_id: str) -> ModelVariance | None:
6803+
"""
6804+
Introspect a mipi ``model_schema_extractor`` CONFIG_SCHEMA per model.
6805+
6806+
The schema bundle dumps one representative model's schema, so every other
6807+
model's requiredness and defaults are recovered here by resolving
6808+
``model_schema`` for each model. A model-driven closure that isn't the
6809+
known extractor lands in ``_UNHANDLED_MODEL_DRIVEN``.
6810+
"""
6811+
schema = getattr(manifest, "config_schema", None)
6812+
code = getattr(schema, "__code__", None)
6813+
if code is None:
6814+
return None
6815+
if code is not _mipi_model_wrapper_code():
6816+
if {"models", "model_schema"} <= set(code.co_freevars):
6817+
_UNHANDLED_MODEL_DRIVEN.add(component_id)
6818+
return None
6819+
import esphome.config_validation as cv
6820+
from esphome.const import CONF_MODEL
6821+
6822+
nonlocals = _closure_nonlocals(schema)
6823+
models = sorted(nonlocals["models"])
6824+
model_schema = nonlocals["model_schema"]
6825+
extra = nonlocals.get("extra") or {}
6826+
fields: dict[str, dict[str, ModelField]] = {}
6827+
# ``model_schema`` logs per resolve (mipi_spi's "No SPI mode specified"),
6828+
# once per model — silence the loop, restore after.
6829+
previous_disable = logging.root.manager.disable
6830+
logging.disable(logging.WARNING)
6831+
try:
6832+
for name in models:
6833+
resolved = model_schema({CONF_MODEL: name, **extra})
6834+
if isinstance(resolved, vol.All):
6835+
resolved = next(v for v in resolved.validators if isinstance(v, vol.Schema))
6836+
for marker in resolved.schema:
6837+
if not isinstance(marker, vol.Marker) or isinstance(marker, cv.GenerateID):
6838+
continue
6839+
raw_default = getattr(marker, "default", vol.UNDEFINED)
6840+
default = raw_default() if callable(raw_default) else vol.UNDEFINED
6841+
fields.setdefault(str(marker.schema), {})[name] = ModelField(
6842+
required=isinstance(marker, vol.Required), default=default
6843+
)
6844+
finally:
6845+
logging.disable(previous_disable)
6846+
return ModelVariance(models=tuple(models), fields=fields)
6847+
6848+
6849+
def _collect_model_variance_by_domain(
6850+
manifest: Any,
6851+
platform_manifests_by_domain: Iterable[tuple[str, Any]],
6852+
component_id: str,
6853+
) -> dict[str, ModelVariance]:
6854+
"""Collect model variance per manifest, keyed by domain ("" for the bare one)."""
6855+
variance_by_domain: dict[str, ModelVariance] = {}
6856+
if (bare := _collect_model_variance(manifest, component_id)) is not None:
6857+
variance_by_domain[""] = bare
6858+
for domain, platform_manifest in platform_manifests_by_domain:
6859+
variance = _collect_model_variance(platform_manifest, f"{domain}.{component_id}")
6860+
if variance is not None:
6861+
variance_by_domain[domain] = variance
6862+
return variance_by_domain
6863+
6864+
67586865
def _int_enum_refined_type(validator: Any) -> RefinedType | None:
67596866
"""Return an ``integer`` refinement iff *validator*'s live enum values are all real ints."""
67606867
values = _extract_enum_values(validator)
@@ -7274,6 +7381,92 @@ def visit(entry: dict, path: tuple[str, ...]) -> None:
72747381
_walk_catalog_entries(entries, visit)
72757382

72767383

7384+
def _apply_model_variance(
7385+
entries: list[dict],
7386+
variance: ModelVariance | None,
7387+
component_id: str,
7388+
) -> None:
7389+
"""
7390+
Correct per-model requiredness the representative-model dump flattened.
7391+
7392+
A field required under only some models splits into gated twins
7393+
(``depends_on: model`` + ``depends_on_value_any``); a field absent from
7394+
some models is gated to the models that carry it; a default that varies
7395+
across models is scrubbed.
7396+
"""
7397+
if variance is None or not variance.fields:
7398+
return
7399+
model_entry = next((e for e in entries if e["key"] == "model"), None)
7400+
options = [o["value"] for o in (model_entry or {}).get("options") or []]
7401+
if set(options) != set(variance.models):
7402+
raise SystemExit(
7403+
f"{component_id}: bundle model options {sorted(options)} != introspected "
7404+
f"models {list(variance.models)}; the schema bundle and the installed "
7405+
"esphome disagree on the model enum."
7406+
)
7407+
for key, per_model in variance.fields.items():
7408+
index = next((i for i, e in enumerate(entries) if e["key"] == key), None)
7409+
if index is None:
7410+
_LOGGER.info(
7411+
"%s: model-specific field %r has no catalog entry; skipped", component_id, key
7412+
)
7413+
continue
7414+
if entries[index].get("depends_on"):
7415+
_LOGGER.warning(
7416+
"%s: %r already gated on %r; model variance skipped",
7417+
component_id,
7418+
key,
7419+
entries[index]["depends_on"],
7420+
)
7421+
continue
7422+
_apply_field_model_variance(entries, index, per_model, options)
7423+
7424+
7425+
def _apply_field_model_variance(
7426+
entries: list[dict],
7427+
index: int,
7428+
per_model: dict[str, ModelField],
7429+
options: list[str],
7430+
) -> None:
7431+
"""Rewrite ``entries[index]`` to the introspected per-model requiredness."""
7432+
entry = entries[index]
7433+
required_models = [m for m in options if m in per_model and per_model[m].required]
7434+
optional_models = [m for m in options if m in per_model and not per_model[m].required]
7435+
absent_models = [m for m in options if m not in per_model]
7436+
defaults = [f.default for f in per_model.values() if f.default is not vol.UNDEFINED]
7437+
keep_default = not defaults or (
7438+
len(defaults) == len(per_model) and all(d == defaults[0] for d in defaults)
7439+
)
7440+
if required_models and optional_models:
7441+
required_twin = {
7442+
**copy.deepcopy(entry),
7443+
"required": True,
7444+
"advanced": False,
7445+
"default_value": None,
7446+
"depends_on": "model",
7447+
"depends_on_value_any": required_models,
7448+
}
7449+
optional_twin = {
7450+
**copy.deepcopy(entry),
7451+
"required": False,
7452+
"depends_on": "model",
7453+
"depends_on_value_any": optional_models,
7454+
}
7455+
if not keep_default:
7456+
optional_twin["default_value"] = None
7457+
entries[index : index + 1] = [required_twin, optional_twin]
7458+
return
7459+
entry["required"] = bool(required_models)
7460+
if required_models:
7461+
entry["advanced"] = False
7462+
entry["default_value"] = None
7463+
elif not keep_default:
7464+
entry["default_value"] = None
7465+
if absent_models:
7466+
entry["depends_on"] = "model"
7467+
entry["depends_on_value_any"] = required_models or optional_models
7468+
7469+
72777470
def _apply_refined_types(
72787471
entries: list[dict],
72797472
refined: dict[tuple[str, ...], RefinedType],
@@ -9115,6 +9308,26 @@ def _fail_on_unhandled_repr_keys() -> None:
91159308
)
91169309

91179310

9311+
def _fail_on_unhandled_schema_shapes() -> None:
9312+
"""Abort on schema shapes the sync can't faithfully convert."""
9313+
_fail_on_unhandled_repr_keys()
9314+
_fail_on_unhandled_model_driven()
9315+
9316+
9317+
def _fail_on_unhandled_model_driven() -> None:
9318+
"""Abort when a model-driven CONFIG_SCHEMA isn't the mipi extractor the sync introspects."""
9319+
if not _UNHANDLED_MODEL_DRIVEN:
9320+
return
9321+
rows = "\n".join(f" {cid}" for cid in sorted(_UNHANDLED_MODEL_DRIVEN))
9322+
raise SystemExit(
9323+
"model-driven CONFIG_SCHEMA closures the sync can't introspect — "
9324+
"per-model requiredness would ship flattened to the representative "
9325+
"model's:\n"
9326+
f"{rows}\n"
9327+
"Extend _collect_model_variance for the new extractor shape."
9328+
)
9329+
9330+
91189331
def _collect_rename_keys(manifest: Any) -> dict[tuple[str, str], SchemaHit]:
91199332
"""
91209333
Walk the live ``CONFIG_SCHEMA`` for ``cv.rename_key`` aliases.

tests/test_components_integration_docs.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,50 @@ async def test_spi_is_multi_conf(catalog: ComponentCatalog) -> None:
3838

3939

4040
async def test_mipi_spi_dc_pin_is_optional(catalog: ComponentCatalog) -> None:
41-
"""The shipped ``display.mipi_spi`` `dc_pin` is not required.
41+
"""The shipped ``display.mipi_spi`` has no ungated required `dc_pin`.
4242
43-
DC exists only on single/octal panels, never on quad AMOLED — requiredness is a
44-
runtime validator, and esphome declares the field ``cv.Optional``. esphome 2026.6.4
45-
dumps it required, so the sync forward-ports 2026.7.0's optional behaviour; without
46-
this the frontend seeds a bogus `dc_pin` on quad displays. Guard the override.
43+
DC exists only on single/octal panels, never on quad AMOLED; the bundle dumps
44+
one representative model's requiredness, corrected by the sync's per-model
45+
introspection. An ungated required `dc_pin` would seed a bogus pin on quad
46+
displays.
4747
"""
4848
body = await catalog.get_body("display.mipi_spi")
4949
assert body is not None
50-
dc = next((e for e in body.config_entries if e.key == "dc_pin"), None)
51-
assert dc is not None
52-
assert dc.required is not True
50+
entries = [e for e in body.config_entries if e.key == "dc_pin"]
51+
assert entries
52+
for entry in entries:
53+
assert entry.required is not True or entry.depends_on == "model"
54+
55+
56+
async def test_epaper_spi_required_fields_are_model_gated(catalog: ComponentCatalog) -> None:
57+
"""The shipped ``display.epaper_spi`` requires pins/dimensions only per model.
58+
59+
The bundle flattens per-model requiredness to the representative model's, which
60+
blocked the add form for models that predefine `cs_pin` / `dc_pin` / `dimensions`
61+
(#2643). Requiredness must ship gated on `model`, with the complement models
62+
keeping an optional entry.
63+
"""
64+
body = await catalog.get_body("display.epaper_spi")
65+
assert body is not None
66+
model = next((e for e in body.config_entries if e.key == "model"), None)
67+
assert model is not None and model.options
68+
model_values = {o.value for o in model.options}
69+
cs_entries = [e for e in body.config_entries if e.key == "cs_pin"]
70+
assert cs_entries and all(e.required is not True for e in cs_entries)
71+
for key in ("dc_pin", "dimensions"):
72+
entries = [e for e in body.config_entries if e.key == key]
73+
required = [e for e in entries if e.required]
74+
optional = [e for e in entries if not e.required]
75+
assert required and optional
76+
gated: set[str] = set()
77+
for entry in entries:
78+
assert entry.depends_on == "model"
79+
assert entry.depends_on_value_any
80+
values = set(entry.depends_on_value_any)
81+
assert values <= model_values
82+
assert not values & gated
83+
gated |= values
84+
assert gated == model_values
5385

5486

5587
async def test_top_level_components_resolved(catalog: ComponentCatalog) -> None:

0 commit comments

Comments
 (0)