mirror of
https://github.com/esphome/esphome.git
synced 2026-06-02 19:18:20 +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):
|
def _emit_dst_rule_fields(prefix, rule):
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from esphome.core import (
|
|||||||
TimePeriodNanoseconds,
|
TimePeriodNanoseconds,
|
||||||
TimePeriodSeconds,
|
TimePeriodSeconds,
|
||||||
)
|
)
|
||||||
|
from esphome.enum import StrEnum
|
||||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
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.helpers import add_class_to_obj, docs_url, list_starts_with
|
||||||
from esphome.schema_extractors import (
|
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):
|
class Optional(vol.Optional):
|
||||||
"""Mark a field as optional and optionally define a default for the field.
|
"""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
|
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
|
during config validation - specifically *not* in the C++ code or the code generation
|
||||||
phase.
|
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)
|
super().__init__(key, default=default)
|
||||||
|
self.visibility: Visibility | None = visibility
|
||||||
|
|
||||||
|
|
||||||
class Required(vol.Required):
|
class Required(vol.Required):
|
||||||
"""Define a field to be required to be set. The validated configuration is guaranteed
|
"""Define a field to be required to be set. The validated configuration is guaranteed
|
||||||
to contain this key.
|
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.
|
- *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)
|
super().__init__(key, msg=msg)
|
||||||
|
self.visibility: Visibility | None = visibility
|
||||||
|
|
||||||
|
|
||||||
class FinalExternalInvalid(Invalid):
|
class FinalExternalInvalid(Invalid):
|
||||||
@@ -2162,16 +2234,45 @@ ENTITY_BASE_SCHEMA = Schema(
|
|||||||
|
|
||||||
ENTITY_BASE_SCHEMA.add_extra(_entity_base_validator)
|
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
|
"""Validate that this component represents a PollingComponent with a configurable
|
||||||
update_interval.
|
update_interval.
|
||||||
|
|
||||||
:param default_update_interval: The default update interval to set for the integration.
|
: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:
|
if default_update_interval is None:
|
||||||
|
# Required → don't honour ``visibility``.
|
||||||
|
# See the docstring for the UX rationale.
|
||||||
return COMPONENT_SCHEMA.extend(
|
return COMPONENT_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
Required(CONF_UPDATE_INTERVAL): update_interval,
|
Required(CONF_UPDATE_INTERVAL): update_interval,
|
||||||
@@ -2181,7 +2282,9 @@ def polling_component_schema(default_update_interval):
|
|||||||
return COMPONENT_SCHEMA.extend(
|
return COMPONENT_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
Optional(
|
Optional(
|
||||||
CONF_UPDATE_INTERVAL, default=default_update_interval
|
CONF_UPDATE_INTERVAL,
|
||||||
|
default=default_update_interval,
|
||||||
|
visibility=visibility,
|
||||||
): update_interval,
|
): update_interval,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1103,6 +1103,18 @@ def convert_keys(converted, schema, path):
|
|||||||
if default_value is not None:
|
if default_value is not None:
|
||||||
result["default"] = str(default_value)
|
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
|
# Do value
|
||||||
convert(v, result, path + f"/{str(k)}")
|
convert(v, result, path + f"/{str(k)}")
|
||||||
if "schema" not in converted:
|
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."""
|
"""update_interval: never must still map to SCHEDULER_DONT_RUN."""
|
||||||
result = config_validation.update_interval("never")
|
result = config_validation.update_interval("never")
|
||||||
assert result.total_milliseconds == SCHEDULER_DONT_RUN
|
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