diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 3295366fea3..29bb01b499d 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -345,7 +345,14 @@ TIME_SCHEMA = cv.Schema( } ), } -).extend(cv.polling_component_schema("15min")) +).extend( + # ``visibility=ADVANCED`` flags the inherited ``update_interval`` + # field for visual editors — the 15min default is correct for + # essentially every user, so editors should keep it tucked under + # "advanced" so it doesn't crowd the form. Validation is + # unaffected; YAML can override as before. + cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) +) def _emit_dst_rule_fields(prefix, rule): diff --git a/esphome/config_validation.py b/esphome/config_validation.py index fbafc5cb07b..c993c1dcc5e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -89,6 +89,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) +from esphome.enum import StrEnum from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG from esphome.helpers import add_class_to_obj, docs_url, list_starts_with from esphome.schema_extractors import ( @@ -281,6 +282,54 @@ RESERVED_IDS = [ ] +class Visibility(StrEnum): + """Schema-driven UI hint for visual editors. + + The values describe how a schema-aware editor (e.g. the + device-builder dashboard catalog via + ``script/build_language_schema.py``) should render the field. + They do NOT affect validation — the YAML still accepts the key + the same way. ESPHome itself ignores the value at runtime; + consumers downstream of the schema dump act on it. + + A field with no ``visibility`` set (the default) renders on the + editor's main form. The two values below are points along a + single axis of "how prominently to surface this": + + - ``ADVANCED`` — render under the editor's "advanced settings" + disclosure. Use for fields whose default is right for ~all + users (e.g. ``update_interval`` on time platforms — 15 min is + universally correct, but power users can still tune the YAML + directly). + - ``YAML_ONLY`` — never render in a visual editor. Use for + knobs that are dangerous to expose in a UI even as advanced + (``setup_priority`` is the canonical example — casual UI + tweaks can break boot). The YAML escape hatch stays + available for the rare power-user override. + + The single-axis shape encodes "yaml-only is strictly stronger + than advanced" at the type level — there's no way to ask for + both at once, and no way to set a contradictory state like + "advanced=False, yaml_only=True". + + Per-field; the dumper walks recursively into nested schemas + and emits each field's setting independently. Cascading + semantics — "a stricter parent makes its descendants at-least + as strict" — belong on the consumer side: the schema marker + is faithfully what the field author wrote, and a consumer that + cares about effective visibility walks the parent chain and + takes the strictest setting. ``YAML_ONLY`` is strictly stronger + than ``ADVANCED``, which is strictly stronger than no setting. + Inner fields can declare their own visibility; an inner + ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, + and the consumer's cascade keeps siblings under the parent at + ``ADVANCED`` regardless of their own (less-strict) setting. + """ + + ADVANCED = "advanced" + YAML_ONLY = "yaml_only" + + class Optional(vol.Optional): """Mark a field as optional and optionally define a default for the field. @@ -295,22 +344,45 @@ class Optional(vol.Optional): In ESPHome, all configuration defaults should be defined with the Optional class during config validation - specifically *not* in the C++ code or the code generation phase. + + See :class:`Visibility` for the ``visibility`` kwarg — a UI + hint for schema-driven editors that doesn't affect validation. """ - def __init__(self, key, default=UNDEFINED): + def __init__( + self, + key, + default=UNDEFINED, + *, + visibility: Visibility | None = None, + ): super().__init__(key, default=default) + self.visibility: Visibility | None = visibility class Required(vol.Required): """Define a field to be required to be set. The validated configuration is guaranteed to contain this key. - All required values should be acceessed with the `config[CONF_]` syntax in code + All required values should be accessed with the `config[CONF_]` syntax in code - *not* the `config.get(CONF_)` syntax. + + See :class:`Visibility` for the ``visibility`` kwarg — a UI + hint for schema-driven editors that doesn't affect validation. + Required fields rarely need it (a required field by definition + needs the user's attention) but the kwarg is exposed for + symmetry so consumers can apply uniform logic across key markers. """ - def __init__(self, key, msg=None): + def __init__( + self, + key, + msg=None, + *, + visibility: Visibility | None = None, + ): super().__init__(key, msg=msg) + self.visibility: Visibility | None = visibility class FinalExternalInvalid(Invalid): @@ -2162,16 +2234,45 @@ ENTITY_BASE_SCHEMA = Schema( ENTITY_BASE_SCHEMA.add_extra(_entity_base_validator) -COMPONENT_SCHEMA = Schema({Optional(CONF_SETUP_PRIORITY): float_}) +COMPONENT_SCHEMA = Schema( + { + # ``setup_priority`` controls the relative order in which + # components are brought up at boot. Wrong values can break + # the boot sequence in subtle ways (e.g. an i2c device set + # to higher priority than the bus). Mark it ``YAML_ONLY`` so + # visual editors never render it — the YAML escape hatch + # stays available for the rare component author who really + # needs to override the default. + Optional(CONF_SETUP_PRIORITY, visibility=Visibility.YAML_ONLY): float_, + } +) -def polling_component_schema(default_update_interval): +def polling_component_schema( + default_update_interval, *, visibility: Visibility | None = None +): """Validate that this component represents a PollingComponent with a configurable update_interval. :param default_update_interval: The default update interval to set for the integration. + :param visibility: When set, propagate to the inherited + ``update_interval`` field's :class:`Visibility` UI hint. Set + this for components whose default cadence is already correct + for ~all users (e.g. time platforms — pass + ``Visibility.ADVANCED``). + + Only honoured on the optional-default branch. When + ``default_update_interval`` is ``None`` the field becomes + ``Required`` (the component has no sensible default cadence and + needs the user to choose), and hiding a Required field behind + an advanced disclosure would be a UX hazard — collapsed-by-default + editors could let the user submit without realising the form has + an unfilled required field. The kwarg is silently ignored on that + path so callers can pass it unconditionally. """ if default_update_interval is None: + # Required → don't honour ``visibility``. + # See the docstring for the UX rationale. return COMPONENT_SCHEMA.extend( { Required(CONF_UPDATE_INTERVAL): update_interval, @@ -2181,7 +2282,9 @@ def polling_component_schema(default_update_interval): return COMPONENT_SCHEMA.extend( { Optional( - CONF_UPDATE_INTERVAL, default=default_update_interval + CONF_UPDATE_INTERVAL, + default=default_update_interval, + visibility=visibility, ): update_interval, } ) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 05ac47bfcce..a7142fa8b5e 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1103,6 +1103,18 @@ def convert_keys(converted, schema, path): if default_value is not None: result["default"] = str(default_value) + # UI hint from ``cv.Optional`` / ``cv.Required`` — surfaced + # for schema consumers (visual editors) that want to render + # advanced / yaml-only fields differently. ESPHome itself + # ignores it at runtime; emitting only when set keeps the + # dump compact and backwards-compatible with markers that + # don't carry the attribute. The value is the str form of + # ``cv.Visibility`` (e.g. ``"advanced"`` / ``"yaml_only"``) + # so consumers don't need an enum import to read it. + visibility = getattr(k, "visibility", None) + if visibility is not None: + result["visibility"] = str(visibility) + # Do value convert(v, result, path + f"/{str(k)}") if "schema" not in converted: diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f038272d8b0..fd6c0e95f2a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -793,3 +793,187 @@ def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" result = config_validation.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN + + +# --------------------------------------------------------------------------- +# Visibility UI-hint kwarg +# --------------------------------------------------------------------------- + + +def test_optional_default_visibility_is_none() -> None: + """An ``Optional`` with no ``visibility`` kwarg reports ``None``. + + Consumers can read the attribute directly with plain attribute + access; absence (``None``) means "render on the editor's main + form." + """ + o = config_validation.Optional("foo") + assert o.visibility is None + + +def test_optional_visibility_advanced() -> None: + """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" + o = config_validation.Optional( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + assert o.visibility is config_validation.Visibility.ADVANCED + + +def test_optional_visibility_yaml_only() -> None: + """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" + o = config_validation.Optional( + "foo", visibility=config_validation.Visibility.YAML_ONLY + ) + assert o.visibility is config_validation.Visibility.YAML_ONLY + + +def test_visibility_str_values_match_dump_emission() -> None: + """``Visibility`` is a ``StrEnum`` whose values are the literal + strings the schema dumper emits. + + The schema bundle consumers (catalog generators, third-party + schema-aware tooling) shouldn't need an enum import to read the + field — pinning the on-the-wire spelling here keeps the dump + contract stable. + """ + assert str(config_validation.Visibility.ADVANCED) == "advanced" + assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + + +def test_optional_visibility_does_not_affect_validation() -> None: + """The kwarg is an advisory UI hint — it must not change how the + validator behaves. A schema with ``visibility`` applied must + accept and reject the same values it would without it. + """ + plain = config_validation.Schema( + {config_validation.Optional("foo", default=42): config_validation.int_} + ) + flagged = config_validation.Schema( + { + config_validation.Optional( + "foo", + default=42, + visibility=config_validation.Visibility.YAML_ONLY, + ): config_validation.int_ + } + ) + # Same accept / default-fill behavior. + assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7} + assert plain({}) == flagged({}) == {"foo": 42} + # Same rejection on bad input. + with pytest.raises(Invalid): + plain({"foo": "not-an-int"}) + with pytest.raises(Invalid): + flagged({"foo": "not-an-int"}) + + +def test_required_default_visibility_is_none() -> None: + """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" + r = config_validation.Required("foo") + assert r.visibility is None + + +def test_required_visibility_kwarg() -> None: + """``Required`` accepts ``visibility`` for symmetry with ``Optional``. + + Required fields rarely need the kwarg, but exposing it lets + consumers apply uniform logic across key markers. + """ + r = config_validation.Required( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + assert r.visibility is config_validation.Visibility.ADVANCED + + +def test_polling_component_schema_visibility_opt_in() -> None: + """``visibility=`` propagates to the inherited ``update_interval``. + + Time platforms pass ``Visibility.ADVANCED``; sensors and other + polling components leave it ``None`` and keep the un-flagged shape. + """ + default = config_validation.polling_component_schema("15min") + advanced = config_validation.polling_component_schema( + "15min", visibility=config_validation.Visibility.ADVANCED + ) + default_keys = {str(k): k for k in default.schema} + advanced_keys = {str(k): k for k in advanced.schema} + assert default_keys["update_interval"].visibility is None + assert ( + advanced_keys["update_interval"].visibility + is config_validation.Visibility.ADVANCED + ) + # The opt-in only touches update_interval — setup_priority + # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA + # in both shapes. + assert ( + default_keys["setup_priority"].visibility + is config_validation.Visibility.YAML_ONLY + ) + assert ( + advanced_keys["setup_priority"].visibility + is config_validation.Visibility.YAML_ONLY + ) + + +def test_polling_component_schema_no_default_ignores_visibility() -> None: + """``visibility`` is silently ignored when the field is Required. + + When ``default_update_interval=None`` the field becomes + ``Required``. Hiding a Required field behind an advanced + disclosure is a UX hazard — a collapsed-by-default editor could + let the user submit without noticing the form has an unfilled + required field. The helper accepts the kwarg unconditionally + for caller ergonomics but doesn't honour it on this branch. + """ + schema = config_validation.polling_component_schema( + None, visibility=config_validation.Visibility.ADVANCED + ) + keys = {str(k): k for k in schema.schema} + assert isinstance(keys["update_interval"], config_validation.Required) + assert keys["update_interval"].visibility is None + + +def test_visibility_marker_is_per_field_no_mutation() -> None: + """Each field's ``visibility`` is recorded as the author wrote it. + + Cascading semantics — "a stricter parent forces its descendants + at-least as strict" — live on the consumer side, not in the + marker itself. The schema marker stays as-written so consumers + can walk the parent chain and compute the effective visibility + themselves; mutating the marker would lose the per-field author + intent. + + Pin both directions of the no-mutation contract: an inner + ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY`` + on the marker (the consumer's effective-visibility cascade + would also report ``YAML_ONLY`` since it's stricter), and an + un-marked inner field stays ``None`` on the marker (the + cascade's job is to compute ``ADVANCED`` from the parent — a + detail this test deliberately doesn't pin, since it's a + consumer concern). + """ + inner_unset = config_validation.Optional("baz") + inner_yaml_only = config_validation.Optional( + "qux", visibility=config_validation.Visibility.YAML_ONLY + ) + parent = config_validation.Optional( + "foo", visibility=config_validation.Visibility.ADVANCED + ) + + # Wire them into a nested schema — none of the markers' own + # ``visibility`` should change as a result. + schema = config_validation.Schema( + { + parent: config_validation.Schema( + { + inner_unset: config_validation.int_, + inner_yaml_only: config_validation.string, + } + ) + } + ) + assert schema # touch the schema so any deferred mutation runs + + assert parent.visibility is config_validation.Visibility.ADVANCED + assert inner_unset.visibility is None + assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY