5555from functools import cache
5656from io import BytesIO
5757from pathlib import Path
58- from types import FunctionType , SimpleNamespace
58+ from types import CodeType , FunctionType , SimpleNamespace
5959from typing import Any , Literal , NamedTuple
6060
6161import 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+
67586865def _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+
72777470def _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+
91189331def _collect_rename_keys (manifest : Any ) -> dict [tuple [str , str ], SchemaHit ]:
91199332 """
91209333 Walk the live ``CONFIG_SCHEMA`` for ``cv.rename_key`` aliases.
0 commit comments