mirror of
https://github.com/esphome/esphome.git
synced 2026-05-23 19:55:33 +08:00
[config_validation] Add a visibility UI-hint kwarg (#16267)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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_<KEY>]` syntax in code
|
||||
All required values should be accessed with the `config[CONF_<KEY>]` syntax in code
|
||||
- *not* the `config.get(CONF_<KEY>)` 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user