[core] Classify entity metadata visibility for the visual editor (#17503)

This commit is contained in:
Jesse Hills
2026-07-12 08:33:29 -04:00
committed by GitHub
parent 91c42381f6
commit e27a14ec70
13 changed files with 193 additions and 57 deletions
+3 -1
View File
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Exclusive(
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
): cv.boolean,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
+3 -1
View File
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
}
)
+3 -1
View File
@@ -131,7 +131,9 @@ _COVER_SCHEMA = (
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
cv.requires_component("mqtt"), cv.boolean
),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic
),
+3 -1
View File
@@ -50,7 +50,9 @@ _EVENT_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent),
cv.GenerateID(): cv.declare_id(Event),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
}
)
+9 -3
View File
@@ -212,9 +212,15 @@ _NUMBER_SCHEMA = (
},
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
): cv.enum(NUMBER_MODES, upper=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
}
)
)
+19 -7
View File
@@ -321,13 +321,25 @@ _SENSOR_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
cv.GenerateID(): cv.declare_id(Sensor),
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(CONF_STATE_CLASS): validate_state_class,
cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category,
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
cv.Optional(CONF_EXPIRE_AFTER): cv.All(
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
): validate_unit_of_measurement,
cv.Optional(
CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED
): validate_accuracy_decimals,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(
CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_state_class,
cv.Optional(
CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED
): sensor_entity_category,
cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.boolean,
cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All(
cv.requires_component("mqtt"),
cv.Any(None, cv.positive_time_period_milliseconds),
),
+3 -1
View File
@@ -78,7 +78,9 @@ _SWITCH_SCHEMA = (
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
}
)
)
+3 -1
View File
@@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor),
cv.GenerateID(): cv.declare_id(TextSensor),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_VALUE): automation.validate_automation({}),
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
+6 -2
View File
@@ -54,7 +54,9 @@ _UPDATE_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation(
single=True
),
@@ -136,7 +138,9 @@ async def to_code(config):
automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(UpdateEntity),
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean),
cv.Optional(
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
): cv.templatable(cv.boolean),
}
),
synchronous=True,
+3 -1
View File
@@ -87,7 +87,9 @@ _VALVE_SCHEMA = (
{
cv.GenerateID(): cv.declare_id(Valve),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic
),
+3 -1
View File
@@ -172,7 +172,9 @@ sorting_group = {
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WEB_SERVER): cv.Schema(
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
+68 -34
View File
@@ -292,10 +292,14 @@ class Visibility(StrEnum):
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":
Three points along a single axis of "how prominently to surface
this", from least to most hidden:
- ``UI`` always render on the editor's main form. Use to
promote an ``Optional`` that would otherwise fall through to
the advanced disclosure (see the default rule below): the
"headline" config a user reaches for first (e.g. a sensor's
``name`` or its primary pin/address).
- ``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
@@ -307,25 +311,35 @@ class Visibility(StrEnum):
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".
Default when unset (``visibility=None``): resolved by the
consumer, not encoded on the marker. A schema-aware editor
treats an ``Optional`` with no setting as ``ADVANCED`` (most
optional knobs have sensible defaults and would clutter the
form), and a ``Required`` with no setting as ``UI`` (a required
field needs the user's attention). Pass an explicit value to
override either default most commonly ``UI`` to keep a
high-value ``Optional`` on the main form.
The single-axis shape encodes the strictness ladder
(``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level
there's no way to set a contradictory state.
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
and emits each field's setting independently, omitting the key
when unset so the dump stays compact and the per-field default
is the consumer's to apply. 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. 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.
and the consumer's cascade keeps a ``UI`` sibling under an
``ADVANCED`` parent at ``ADVANCED`` regardless of its own
(less-strict) setting.
"""
UI = "ui"
ADVANCED = "advanced"
YAML_ONLY = "yaml_only"
@@ -347,6 +361,9 @@ class Optional(vol.Optional):
See :class:`Visibility` for the ``visibility`` kwarg a UI
hint for schema-driven editors that doesn't affect validation.
Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED``
by schema-aware editors; pass ``Visibility.UI`` to keep it on the
main form.
"""
def __init__(
@@ -369,9 +386,11 @@ class Required(vol.Required):
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.
Required fields rarely need it: left unset, a ``Required`` is
treated as on the main form (``Visibility.UI``) by schema-aware
editors, since a required field needs the user's attention. The
kwarg is exposed for symmetry so consumers can apply uniform
logic across key markers.
"""
def __init__(
@@ -2274,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema(
}
)
# Per-entity MQTT plumbing — integration metadata, never a primary UI field.
MQTT_COMPONENT_SCHEMA = Schema(
{
Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos),
Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean),
Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean),
Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos),
Optional(CONF_STATE_TOPIC): All(
Optional(CONF_QOS, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), mqtt_qos
),
Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), boolean
),
Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), boolean
),
Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), mqtt_qos
),
Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), templatable(publish_topic)
),
Optional(CONF_AVAILABILITY): All(
Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA)
),
}
@@ -2291,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema(
MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend(
{
Optional(CONF_COMMAND_TOPIC): All(
Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), templatable(subscribe_topic)
),
Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean),
Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All(
requires_component("mqtt"), boolean
),
}
)
@@ -2369,12 +2399,16 @@ def string_no_slash(value):
ENTITY_BASE_SCHEMA = Schema(
{
Optional(CONF_NAME): _validate_entity_name,
Optional(CONF_INTERNAL): boolean,
Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean,
Optional(CONF_ICON): icon,
Optional(CONF_ENTITY_CATEGORY): entity_category,
Optional(CONF_DEVICE_ID): sub_device_id,
# The name is every entity's headline field — keep it on the
# main form rather than letting it fall through to advanced.
Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name,
Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean,
Optional(
CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED
): boolean,
Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon,
Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category,
Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id,
}
)
+67 -3
View File
@@ -1174,9 +1174,10 @@ def test_update_interval__never_passes_through() -> None:
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."
The marker stays faithful to what the author wrote: ESPHome does
not encode the default on it. Resolving ``None`` to an effective
visibility is the consumer's job — a schema-aware editor treats an
unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`).
"""
o = cv.Optional("foo")
assert o.visibility is None
@@ -1194,6 +1195,17 @@ def test_optional_visibility_yaml_only() -> None:
assert o.visibility is cv.Visibility.YAML_ONLY
def test_optional_visibility_ui() -> None:
"""``visibility=Visibility.UI`` is recorded on the marker.
``UI`` promotes an ``Optional`` onto the editor's main form,
overriding the consumer's default of ``ADVANCED`` for unset
optionals.
"""
o = cv.Optional("foo", visibility=cv.Visibility.UI)
assert o.visibility is cv.Visibility.UI
def test_visibility_str_values_match_dump_emission() -> None:
"""``Visibility`` is a ``StrEnum`` whose values are the literal
strings the schema dumper emits.
@@ -1203,6 +1215,7 @@ def test_visibility_str_values_match_dump_emission() -> None:
field pinning the on-the-wire spelling here keeps the dump
contract stable.
"""
assert str(cv.Visibility.UI) == "ui"
assert str(cv.Visibility.ADVANCED) == "advanced"
assert str(cv.Visibility.YAML_ONLY) == "yaml_only"
@@ -1325,6 +1338,57 @@ def test_visibility_marker_is_per_field_no_mutation() -> None:
assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY
def test_entity_metadata_visibility_hints() -> None:
"""Entity and value-describing metadata is classified for visual editors.
The headline ``name`` stays on the main form (``UI``); descriptive
metadata (device_class, unit, ), presentation options, and per-entity
integration plumbing (MQTT, web_server ordering) fall to the advanced
disclosure (``ADVANCED``).
"""
advanced = cv.Visibility.ADVANCED
entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema}
assert entity_base["name"].visibility is cv.Visibility.UI
for field in (
"icon",
"internal",
"disabled_by_default",
"entity_category",
"device_id",
):
assert entity_base[field].visibility is advanced, field
mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema}
for field in ("qos", "retain", "discovery", "state_topic", "availability"):
assert mqtt[field].visibility is advanced, field
from esphome.components import binary_sensor, number, sensor
from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA
sensor_markers = {str(k): k for k in sensor.sensor_schema().schema}
for field in (
"unit_of_measurement",
"accuracy_decimals",
"device_class",
"state_class",
"force_update",
):
assert sensor_markers[field].visibility is advanced, field
binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema}
assert binary["device_class"].visibility is advanced
number_markers = {str(k): k for k in number.number_schema(number.Number).schema}
assert number_markers["mode"].visibility is advanced
assert number_markers["device_class"].visibility is advanced
# The whole per-entity web_server block is advanced; children inherit
# via the consumer cascade, so only the parent key carries the hint.
web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema}
assert web["web_server"].visibility is advanced
def _wrap_str(value: str) -> ESPHomeDataBase:
"""Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value."""
return make_data_base(value)