"""Helpers for config validation using voluptuous.""" from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress import copy from datetime import datetime from ipaddress import ( AddressValueError, IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address, ip_network, ) import logging import os from pathlib import Path import re from string import ascii_letters, digits import typing import uuid as uuid_ import voluptuous as vol from esphome import core import esphome.codegen as cg from esphome.const import ( ALLOWED_NAME_CHARS, CONF_AVAILABILITY, CONF_COMMAND_RETAIN, CONF_COMMAND_TOPIC, CONF_DAY, CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_DISCOVERY, CONF_ENTITY_CATEGORY, CONF_HOUR, CONF_ICON, CONF_ID, CONF_INTERNAL, CONF_MINUTE, CONF_MONTH, CONF_NAME, CONF_PASSWORD, CONF_PATH, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_REF, CONF_RETAIN, CONF_SECOND, CONF_SETUP_PRIORITY, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, CONF_TOOLCHAIN, CONF_TOPIC, CONF_TYPE, CONF_TYPE_ID, CONF_UPDATE_INTERVAL, CONF_URL, CONF_USERNAME, CONF_VALUE, CONF_YEAR, ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_NONE, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, PLATFORM_RP2, SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, Framework, Toolchain, __version__ as ESPHOME_VERSION, ) from esphome.core import ( CORE, HexInt, Lambda, TimePeriod, TimePeriodMicroseconds, TimePeriodMilliseconds, TimePeriodMinutes, TimePeriodNanoseconds, TimePeriodSeconds, Version, ) from esphome.enum import StrEnum from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG from esphome.helpers import ( FALSY_BOOL_STRINGS, TRUTHY_BOOL_STRINGS, add_class_to_obj, docs_url, list_starts_with, ) from esphome.schema_extractors import ( SCHEMA_EXTRACT, schema_extractor, schema_extractor_list, schema_extractor_registry, schema_extractor_typed, ) # Deprecated re-export for external components; remove before 2027.2.0 # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base if typing.TYPE_CHECKING: from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) # pylint: disable=invalid-name Schema = _Schema All = vol.All Coerce = vol.Coerce Range = vol.Range Invalid = vol.Invalid MultipleInvalid = vol.MultipleInvalid Any = vol.Any Lower = vol.Lower Upper = vol.Upper Length = vol.Length Exclusive = vol.Exclusive Inclusive = vol.Inclusive Unique = vol.Unique ALLOW_EXTRA = vol.ALLOW_EXTRA UNDEFINED = vol.UNDEFINED RequiredFieldInvalid = vol.RequiredFieldInvalid # this sentinel object can be placed in an 'Invalid' path to say # the rest of the error path is relative to the root config path ROOT_CONFIG_PATH = object() def ByteLength(*, max: int) -> Callable[[str], str]: """Validate that the UTF-8 byte length of a string does not exceed max. Use instead of Length() when the limit must apply to encoded bytes, not characters (e.g. for protobuf length-varint constraints). """ def validator(value: str) -> str: byte_len = len(str(value).encode("utf-8")) if byte_len > max: raise Invalid( f"String is too long ({byte_len} bytes, max {max}). " f"Multibyte characters count as multiple bytes." ) return value return validator RESERVED_IDS = [ # C++ keywords https://en.cppreference.com/w/cpp/keyword "alarm", "alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", "bool", "break", "case", "catch", "char", "char16_t", "char32_t", "class", "clock", "compl", "concept", "const", "constexpr", "const_cast", "continue", "decltype", "default", "delete", "do", "double", "dynamic_cast", "else", "enum", "explicit", "export", "export", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", "operator", "or", "or_eq", "private", "protected", "public", "register", "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", "static", "static_assert", "static_cast", "struct", "switch", "template", "text", "this", "thread_local", "throw", "true", "try", "typedef", "typeid", "typename", "union", "unsigned", "using", "virtual", "void", "volatile", "wchar_t", "while", "xor", "xor_eq", "App", "pinMode", "delay", "delayMicroseconds", "digitalRead", "digitalWrite", "INPUT", "OUTPUT", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "int8_t", "int16_t", "int32_t", "int64_t", "close", "pause", "sleep", "open", "setup", "loop", "spi0", "spi1", "uart0", "uart1", "uart2", # ESP32 ROM functions "crc16_be", "crc16_le", "crc32_be", "crc32_le", "crc8_be", "crc8_le", "dbg_state", "debug_timer", "one_bits", "recv_packet", "send_packet", "check_pos", "software_reset", ] 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. 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 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. 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, 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 a ``UI`` sibling under an ``ADVANCED`` parent at ``ADVANCED`` regardless of its own (less-strict) setting. """ UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" class Optional(vol.Optional): """Mark a field as optional and optionally define a default for the field. When no default is defined, the validated config will not contain the key. You can check if the key is defined with 'CONF_ in config'. Or to access the key and return None if it does not exist, call config.get(CONF_) If a default *is* set, the resulting validated config will always contain the default value. You can therefore directly access the value using the 'config[CONF_]' syntax. 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. 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__( 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 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: 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__( self, key, msg=None, *, visibility: Visibility | None = None, ): super().__init__(key, msg=msg) self.visibility: Visibility | None = visibility def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema: """Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``. Lets a platform override the editor :class:`Visibility` of fields it inherits from a shared schema builder — without that builder needing a visibility parameter of its own. The canonical use is a ``template`` platform promoting the value metadata its user is expected to define (``device_class``, ``unit_of_measurement``, …) onto the main form: CONFIG_SCHEMA = cv.with_visibility( sensor.sensor_schema(TemplateSensor), cv.Visibility.UI, CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, ) The original marker's key, default and validator are preserved; only the visibility changes, and the input ``schema`` is left untouched. Raises if a requested key is not present so typos fail at schema-build time. """ wanted = {str(k) for k in keys} overrides = {} for marker, validator in schema.schema.items(): if str(marker) in wanted: marker = copy.copy(marker) marker.visibility = visibility overrides[marker] = validator if missing := wanted - {str(m) for m in overrides}: raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}") return schema.extend(overrides) class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" def check_not_templatable(value): if isinstance(value, Lambda): raise Invalid("This option is not templatable!") def alphanumeric(value): if value is None: raise Invalid("string value is None") value = str(value) if not value.isalnum(): raise Invalid(f"{value} is not alphanumeric") return value def valid_name(value): value = string_strict(value) if CORE.vscode: # If the value is a substitution, it can't be validated until the substitution # is actually made. sub_match = VARIABLE_PROG.search(value) if sub_match: return value for c in value: if c not in ALLOWED_NAME_CHARS: raise Invalid( f"'{c}' is an invalid character for names. Valid characters are: " f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)" ) return value def string(value): """Validate that a configuration value is a string. If not, automatically converts to a string. Note that this can be lossy, for example the input value 60.00 (float) will be turned into "60.0" (string). For values where this could be a problem `string_strict` has to be used. """ check_not_templatable(value) if isinstance(value, (dict, list)): raise Invalid("string value cannot be dictionary or list.") if isinstance(value, bool): raise Invalid( "Auto-converted this value to boolean, please wrap the value in quotes." ) if isinstance(value, str): return value if value is not None: return str(value) raise Invalid("string value is None") def string_strict(value): """Like string, but only allows strings, and does not automatically convert other types to strings.""" check_not_templatable(value) if isinstance(value, str): return value raise Invalid( f"Must be string, got {type(value)}. did you forget putting quotes around the value?" ) # Substring fallbacks for fields whose validator isn't explicitly wrapped in # ``cv.sensitive``. Frontends and dump tooling should prefer the explicit # marker; this list exists so we still mask obvious leaks in unmigrated or # third-party schemas. Kept here as the single source of truth. SENSITIVE_KEY_FRAGMENTS: frozenset[str] = frozenset( { "password", "passcode", "secret", "token", "api_key", "apikey", "psk", } ) class SensitiveValidator: """Marker wrapper that flags a field as containing sensitive data (passwords, encryption keys, PSKs, tokens). Frontends and dump tooling detect this marker to mask the value; validation behavior is delegated to the inner validator. """ def __init__(self, inner: Callable[[typing.Any], typing.Any]) -> None: self.inner = inner def __call__(self, value: typing.Any) -> typing.Any: validated = self.inner(value) # Tag string results so yaml_util.dump can mask them. Non-string # results pass through unchanged; already-tagged values are not # re-wrapped to keep nested cv.sensitive applications idempotent. if isinstance(validated, str) and not isinstance(validated, SensitiveStr): return SensitiveStr(validated) return validated def __repr__(self) -> str: # Mirror the inner validator's repr so ``build_language_schema``'s # ``known_schemas``/``extended_schemas`` dedup (keyed on ``repr(schema)``) # treats two wrappers around the same inner as identical, and so # voluptuous error messages stay readable. return repr(self.inner) def sensitive( inner: Callable[[typing.Any], typing.Any] = string, ) -> SensitiveValidator: """Mark a field as sensitive so that frontends mask it and dump tooling redacts it. Validation behavior is identical to ``inner`` (defaults to ``cv.string``). """ return SensitiveValidator(inner) def icon(value): """Validate that a given config value is a valid icon.""" from esphome.core.config import ICON_MAX_LENGTH value = string_strict(value) if not value: return value if not re.match("^[\\w\\-]+:[\\w\\-]+$", value): raise Invalid( 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' ) byte_len = len(value.encode("utf-8")) if byte_len > ICON_MAX_LENGTH: raise Invalid( f"Icon string is too long ({byte_len} bytes, max {ICON_MAX_LENGTH}). " "Icons are stored in PROGMEM with a 64-byte buffer limit." ) return value @schema_extractor("use_id") def sub_device_id(value: str | None) -> core.ID | None: # Lazy import to avoid circular imports from esphome.core.config import Device if value == SCHEMA_EXTRACT: return Device if not value: return None return use_id(Device)(value) def boolean(value): """Validate the given config option to be a boolean. This option allows a bunch of different ways of expressing boolean values: - instance of boolean - 'true'/'false' - 'yes'/'no' - 'enable'/disable """ check_not_templatable(value) if isinstance(value, bool): return value if isinstance(value, str): value = value.lower() if value in TRUTHY_BOOL_STRINGS: return True if value in FALSY_BOOL_STRINGS: return False raise Invalid( f"Expected boolean value, but cannot convert {value} to a boolean. Please use 'true' or 'false'" ) def boolean_false(value): """Validate the given config option to be a boolean, set to False. This option allows a bunch of different ways of expressing boolean values: - instance of boolean - 'true'/'false' - 'yes'/'no' - 'enable'/disable """ if boolean(value): raise Invalid("Expected boolean value to be false") return False @schema_extractor_list def ensure_list(*validators): """Validate this configuration option to be a list. If the config value is not a list, it is automatically converted to a single-item list. None and empty dictionaries are converted to empty lists. """ user = All(*validators) list_schema = Schema([user]) def validator(value): check_not_templatable(value) if value is None or (isinstance(value, dict) and not value): return [] if not isinstance(value, list): return [user(value)] return list_schema(value) return validator def hex_int(value): """Validate the given value to be a hex integer. This is mostly for cosmetic purposes of the generated code. """ return HexInt(int_(value)) def int_to_hex_string(value: int | str) -> str: """Convert an integer to a hex string (e.g. 64 -> '0x40'). Pass-through strings.""" if isinstance(value, int): return f"0x{value:X}" return value def int_(value): """Validate that the config option is an integer. Automatically also converts strings to ints. """ check_not_templatable(value) if isinstance(value, int): return value if isinstance(value, float): if int(value) == value: return int(value) raise Invalid( f"This option only accepts integers with no fractional part. Please remove the fractional part from {value}" ) value = string_strict(value).lower() base = 10 if value.startswith("0x"): base = 16 try: return int(value, base) except ValueError: raise Invalid( f"Expected integer, but cannot parse {value} as an integer" ) from None def int_range(min=None, max=None, min_included=True, max_included=True): """Validate that the config option is an integer in the given range.""" if min is not None: assert isinstance(min, int) if max is not None: assert isinstance(max, int) return All( int_, Range(min=min, max=max, min_included=min_included, max_included=max_included), ) def hex_int_range(min=None, max=None, min_included=True, max_included=True): """Validate that the config option is an integer in the given range.""" return All( hex_int, Range(min=min, max=max, min_included=min_included, max_included=max_included), ) def float_range(min=None, max=None, min_included=True, max_included=True): """Validate that the config option is a floating point number in the given range.""" if min is not None: assert isinstance(min, (int, float)) if max is not None: assert isinstance(max, (int, float)) return All( float_, Range(min=min, max=max, min_included=min_included, max_included=max_included), ) port = int_range(min=1, max=65535) float_ = Coerce(float) positive_float = float_range(min=0) zero_to_one_float = float_range(min=0, max=1) negative_one_to_one_float = float_range(min=-1, max=1) positive_int = int_range(min=0) positive_not_null_int = int_range(min=0, min_included=False) positive_not_null_float = float_range(min=0, min_included=False) def validate_id_name(value): """Validate that the given value would be a valid C++ identifier name.""" value = string(value) if not value: raise Invalid("ID must not be empty") if value[0].isdigit(): raise Invalid("First character in ID cannot be a digit.") if "-" in value: raise Invalid( "Dashes are not supported in IDs, please use underscores instead." ) if CORE.vscode: # If the value is a substitution, it can't be validated until the substitution # is actually made sub_match = VARIABLE_PROG.match(value) if sub_match: return value valid_chars = f"{ascii_letters + digits}_" for char in value: if char not in valid_chars: raise Invalid( f"IDs must only consist of upper/lowercase characters, the underscorecharacter and numbers. The character '{char}' cannot be used" ) if value in RESERVED_IDS: raise Invalid(f"ID '{value}' is reserved internally and cannot be used") if value in CORE.loaded_integrations: raise Invalid( f"ID '{value}' conflicts with the name of an esphome integration, please use another ID name." ) return value def use_id(type): """Declare that this configuration option should point to an ID with the given type.""" @schema_extractor("use_id") def validator(value): if value == SCHEMA_EXTRACT: return type check_not_templatable(value) if value is None: return core.ID(None, is_declaration=False, type=type) if ( isinstance(value, core.ID) and value.is_declaration is False and value.type is type ): return value return core.ID(validate_id_name(value), is_declaration=False, type=type) return validator def declare_id(type): """Declare that this configuration option should be used to declare a variable ID with the given type. If two IDs with the same name exist, a validation error is thrown. """ @schema_extractor("declare_id") def validator(value): if value == SCHEMA_EXTRACT: return type check_not_templatable(value) if value is None: return core.ID(None, is_declaration=True, type=type) return core.ID(validate_id_name(value), is_declaration=True, type=type) return validator def templatable(other_validators): """Validate that the configuration option can (optionally) be templated. The user can declare a value as template by using the '!lambda' tag. In that case, validation is skipped. Otherwise (if the value is not templated) the validator given as the first argument to this method is called. """ schema = Schema(other_validators) @schema_extractor("templatable") def validator(value): if value == SCHEMA_EXTRACT: return other_validators if isinstance(value, Lambda): return returning_lambda(value) if isinstance(other_validators, dict): return schema(value) return schema(value) return validator def only_on(platforms): """Validate that this option can only be specified on the given target platforms.""" if not isinstance(platforms, list): platforms = [platforms] def validator_(obj): if CORE.target_platform not in platforms: raise Invalid(f"This feature is only available on {platforms}") return obj return validator_ def only_with_framework( frameworks: Framework | str | list[Framework | str], suggestions=None ): """Validate that this option can only be specified on the given frameworks.""" if not isinstance(frameworks, list): frameworks = [frameworks] frameworks = [Framework(framework) for framework in frameworks] if suggestions is None: suggestions = {} def validator_(obj): if CORE.target_framework not in frameworks: err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}" if suggestion := suggestions.get(CORE.target_framework): (component, docs_path) = suggestion err_str += f"\nPlease use '{component}'" if docs_path: err_str += f": {docs_url(path=f'components/{docs_path}')}" raise Invalid(err_str) return obj return validator_ only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_nrf52 = only_on(PLATFORM_NRF52) only_on_rp2 = only_on(PLATFORM_RP2) # CORE.data key for the "deprecation warning already fired this run" flag. # Deduped via CORE.data (cleared between runs) to match the framework-alias # pattern; one warning per `esphome config|compile|run` invocation is enough. _ONLY_ON_RP2040_DEPRECATED_KEY = "_cv_only_on_rp2040_deprecated_warned" def only_on_rp2040(obj): """Deprecated — kept as a back-compat shim for external custom components. Pre-RP2350, this was the family check for the RP2 platform; with RP2350 landing under the same target platform, the variant axis is now exposed by the rp2 component itself. New code should use one of: * :func:`only_on_rp2` — family-level gate (matches the esp32 pattern; same semantics as the pre-RP2350 ``only_on_rp2040``). * ``rp2.only_on_variant(supported=[VARIANT_RP2040])`` — variant-level gate, rejects RP2350 boards on the rp2 platform. Scheduled for removal in 2027.7.0. """ if not CORE.data.get(_ONLY_ON_RP2040_DEPRECATED_KEY): _LOGGER.warning( "cv.only_on_rp2040 is deprecated; use cv.only_on_rp2 for the " "family gate, or rp2.only_on_variant(supported=[VARIANT_RP2040]) " "for the variant gate. Removed in 2027.7.0." ) CORE.data[_ONLY_ON_RP2040_DEPRECATED_KEY] = True return only_on_rp2(obj) only_with_arduino = only_with_framework(Framework.ARDUINO) # Adapted from: # https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666 def has_at_least_one_key(*keys): """Validate that at least one of the given keys exist in the config.""" def validate(obj): """Test keys exist in dict.""" if not isinstance(obj, dict): raise Invalid("expected dictionary") if not any(k in keys for k in obj): raise Invalid(f"Must contain at least one of {', '.join(keys)}.") return obj return validate def has_exactly_one_key(*keys): """Validate that exactly one of the given keys exist in the config.""" def validate(obj): if not isinstance(obj, dict): raise Invalid("expected dictionary") number = sum(k in keys for k in obj) if number > 1: raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.") if number < 1: raise Invalid(f"Must contain exactly one of {', '.join(keys)}.") return obj return validate def has_at_most_one_key(*keys): """Validate that at most one of the given keys exist in the config.""" def validate(obj): if not isinstance(obj, dict): raise Invalid("expected dictionary") used = set(obj) & set(keys) if len(used) > 1: msg = "Cannot specify more than one of '" + "', '".join(used) + "'." raise MultipleInvalid([Invalid(msg, path=[k]) for k in used]) return obj return validate def has_none_or_all_keys(*keys): """Validate that none or all of the given keys exist in the config.""" def validate(obj): if not isinstance(obj, dict): raise Invalid("expected dictionary") number = sum(k in keys for k in obj) if number != 0 and number != len(keys): raise Invalid(f"Must specify either none or all of {', '.join(keys)}.") return obj return validate TIME_PERIOD_ERROR = ( "Time period {} should be format number + unit, for example 5ms, 5s, 5min, 5h" ) time_period_dict = All( Schema( { Optional("days"): float_, Optional("hours"): float_, Optional("minutes"): float_, Optional("seconds"): float_, Optional("milliseconds"): float_, Optional("microseconds"): float_, } ), has_at_least_one_key( "days", "hours", "minutes", "seconds", "milliseconds", "microseconds" ), lambda value: TimePeriod(**value), ) def time_period_str_colon(value): """Validate and transform time offset with format HH:MM[:SS].""" if isinstance(value, int): raise Invalid("Make sure you wrap time values in quotes") if not isinstance(value, str): raise Invalid(TIME_PERIOD_ERROR.format(value)) try: parsed = [int(x) for x in value.split(":")] except ValueError: raise Invalid(TIME_PERIOD_ERROR.format(value)) from None if len(parsed) == 2: hour, minute = parsed second = 0 elif len(parsed) == 3: hour, minute, second = parsed else: raise Invalid(TIME_PERIOD_ERROR.format(value)) return TimePeriod(hours=hour, minutes=minute, seconds=second) def time_period_str_unit(value): """Validate and transform time period with time unit and integer value.""" check_not_templatable(value) if isinstance(value, int): raise Invalid( f"Don't know what '{value}' means as it has no time *unit*! Did you mean '{value}s'?" ) if isinstance(value, TimePeriod): value = str(value) if not isinstance(value, str): raise Invalid("Expected string for time period with unit.") unit_to_kwarg = { "ns": "nanoseconds", "nanoseconds": "nanoseconds", "us": "microseconds", "µs": "microseconds", "microseconds": "microseconds", "ms": "milliseconds", "milliseconds": "milliseconds", "s": "seconds", "sec": "seconds", "seconds": "seconds", "min": "minutes", "minutes": "minutes", "h": "hours", "hours": "hours", "d": "days", "days": "days", } match = re.match(r"^([-+]?[0-9]*\.?[0-9]*)\s*(\w*)$", value) if match is None: raise Invalid(f"Expected time period with unit, got {value}") kwarg = unit_to_kwarg[one_of(*unit_to_kwarg)(match.group(2))] try: return TimePeriod(**{kwarg: float(match.group(1))}) except ValueError as e: raise Invalid(e) from e def time_period_in_milliseconds_(value): if value.microseconds is not None and value.microseconds != 0: raise Invalid("Maximum precision is milliseconds") return TimePeriodMilliseconds(**value.as_dict()) def time_period_in_microseconds_(value): if value.nanoseconds is not None and value.nanoseconds != 0: raise Invalid("Maximum precision is microseconds") return TimePeriodMicroseconds(**value.as_dict()) def time_period_in_nanoseconds_(value): return TimePeriodNanoseconds(**value.as_dict()) def time_period_in_seconds_(value): if value.nanoseconds is not None and value.nanoseconds != 0: raise Invalid("Maximum precision is seconds") if value.microseconds is not None and value.microseconds != 0: raise Invalid("Maximum precision is seconds") if value.milliseconds is not None and value.milliseconds != 0: raise Invalid("Maximum precision is seconds") return TimePeriodSeconds(**value.as_dict()) def time_period_in_minutes_(value): if value.nanoseconds is not None and value.nanoseconds != 0: raise Invalid("Maximum precision is minutes") if value.microseconds is not None and value.microseconds != 0: raise Invalid("Maximum precision is minutes") if value.milliseconds is not None and value.milliseconds != 0: raise Invalid("Maximum precision is minutes") if value.seconds is not None and value.seconds != 0: raise Invalid("Maximum precision is minutes") return TimePeriodMinutes(**value.as_dict()) def update_interval(value): if value == "never": return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) result = positive_time_period_milliseconds(value) # 0ms was historically (mis)used as a pseudo-loop() mechanism for # PollingComponents. Under the hood it calls set_interval(0), which # causes Scheduler::call() to spin (WDT reset in the field). Coerce # to 1ms so existing configs keep working at ~1kHz instead of # spinning. Don't hard-fail so configs don't break on upgrade; # authors should migrate to HighFrequencyLoopRequester (C++) for # true run-every-loop behaviour. if result.total_milliseconds == 0: _LOGGER.warning( "update_interval of 0ms is not supported - coercing to 1ms. " "A literal 0ms schedule would spin the main loop (the scheduled " "item would always be due, so the scheduler would never yield " "back) and trigger a watchdog reset. Set update_interval to a " "non-zero value such as 1ms or higher. (Custom C++ components " "that need true run-every-loop behaviour should override loop() " "with HighFrequencyLoopRequester instead.)" ) return TimePeriodMilliseconds(milliseconds=1) return result time_period = Any(time_period_str_unit, time_period_str_colon, time_period_dict) positive_time_period = All(time_period, Range(min=TimePeriod())) positive_time_period_milliseconds = All( positive_time_period, time_period_in_milliseconds_ ) positive_time_period_seconds = All(positive_time_period, time_period_in_seconds_) positive_time_period_minutes = All(positive_time_period, time_period_in_minutes_) time_period_microseconds = All(time_period, time_period_in_microseconds_) positive_time_period_microseconds = All( positive_time_period, time_period_in_microseconds_ ) positive_time_period_nanoseconds = All( positive_time_period, time_period_in_nanoseconds_ ) positive_not_null_time_period = All( time_period, Range(min=TimePeriod(), min_included=False) ) def time_of_day(value): return date_time(date=False, time=True)(value) def date_time(date: bool, time: bool): pattern_str = r"^" # Start of string if date: pattern_str += r"\d{4}-\d{1,2}-\d{1,2}" if time: pattern_str += r" " if time: pattern_str += ( r"\d{1,2}:\d{2}" # Hour/Minute r"(:\d{2})?" # 1. Seconds r"(" # 2. Optional AM/PM group r"(\s)?" # 3. Optional Space r"(?:AM|PM|am|pm)" # AM/PM string matching r")?" # End optional AM/PM group ) pattern_str += r"$" # End of string pattern = re.compile(pattern_str) exc_message = "" if date: exc_message += "date" if time: exc_message += "time" schema = Schema({}) if date: schema = schema.extend( { Required(CONF_YEAR): int_range(min=1970, max=3000), Required(CONF_MONTH): int_range(min=1, max=12), Required(CONF_DAY): int_range(min=1, max=31), } ) if time: schema = schema.extend( { Required(CONF_HOUR): int_range(min=0, max=23), Required(CONF_MINUTE): int_range(min=0, max=59), Required(CONF_SECOND): int_range(min=0, max=59), } ) def validator(value): if isinstance(value, dict): return schema(value) value = string(value) match = pattern.match(value) if match is None: # pylint: disable=raise-missing-from raise Invalid(f"Invalid {exc_message}: {value}") if time: has_seconds = match[1] is not None has_ampm = match[2] is not None has_ampm_space = match[3] is not None format = "" if date: format += "%Y-%m-%d" if time: format += " " if time: if has_ampm: format += "%I:%M" else: format += "%H:%M" if has_seconds: format += ":%S" if has_ampm_space: format += " " if has_ampm: format += "%p" try: # The generated format never includes %z/%Z, so this parses a # naive wall-clock date/time by design. date_obj = datetime.strptime(value, format) # noqa: DTZ007 except ValueError as err: raise Invalid(f"Invalid {exc_message}: {err}") from err return_value = {} if date: return_value[CONF_YEAR] = date_obj.year return_value[CONF_MONTH] = date_obj.month return_value[CONF_DAY] = date_obj.day if time: return_value[CONF_HOUR] = date_obj.hour return_value[CONF_MINUTE] = date_obj.minute return_value[CONF_SECOND] = date_obj.second if has_seconds else 0 return schema(return_value) return validator def mac_address(value): value = string_strict(value) parts = value.split(":") if len(parts) != 6: raise Invalid("MAC Address must consist of 6 : (colon) separated parts") parts_int = [] if any(len(part) != 2 for part in parts): raise Invalid("MAC Address must be format XX:XX:XX:XX:XX:XX") for part in parts: try: parts_int.append(int(part, 16)) except ValueError: raise Invalid( "MAC Address parts must be hexadecimal values from 00 to FF" ) from None return core.MACAddress(*parts_int) _BIND_KEY_MISSING = object() class BindKeyValidator(SensitiveValidator): """Sensitive validator for a 16-byte hex bind/encryption key. Use bare as a validator (``cv.bind_key``) for the default error wording, or call it with a custom ``name`` (``cv.bind_key(name="Decryption key")``) to get a validator with tailored error messages. Either way the value is marked sensitive so frontends mask it and dump tooling redacts it. """ def __init__(self, name: str = "Bind key") -> None: self._name = name super().__init__(self._validate) def _validate(self, value: typing.Any) -> str: value = string_strict(value) parts = [value[i : i + 2] for i in range(0, len(value), 2)] if len(parts) != 16: raise Invalid(f"{self._name} must consist of 16 hexadecimal numbers") parts_int = [] if any(len(part) != 2 for part in parts): raise Invalid(f"{self._name} must be format XX") for part in parts: try: parts_int.append(int(part, 16)) except ValueError: raise Invalid( f"{self._name} must be hex values from 00 to FF" ) from None return "".join(f"{part:02X}" for part in parts_int) def __call__( self, value: typing.Any = _BIND_KEY_MISSING, *, name: str | None = None ) -> typing.Any: if value is _BIND_KEY_MISSING: # Factory usage: return a validator with customized error wording. return BindKeyValidator(name if name is not None else self._name) if name is not None and name != self._name: # Direct validation with a one-off custom name. return BindKeyValidator(name)(value) return super().__call__(value) def __repr__(self) -> str: # ``self.inner`` is a bound method of this instance, so the inherited # ``SensitiveValidator.__repr__`` (which returns ``repr(self.inner)``) # would recurse infinitely. Provide a stable, name-keyed repr instead so # ``build_language_schema`` dedup and voluptuous errors stay sane. return f"bind_key({self._name!r})" bind_key = BindKeyValidator() def uuid(value): return Coerce(uuid_.UUID)(value) METRIC_SUFFIXES = { "Q": 1e30, # Quetta "R": 1e27, # Ronna "Y": 1e24, # Yotta "Z": 1e21, # Zetta "E": 1e18, # Exa "P": 1e15, # Peta "T": 1e12, # Tera "G": 1e9, # Giga "M": 1e6, # Mega "k": 1e3, # Kilo "h": 1e2, # Hecto "da": 1e1, # Deca "": 1e0, # No prefix "d": 1e-1, # Deci "c": 1e-2, # Centi "m": 1e-3, # Milli "µ": 1e-6, # Micro "u": 1e-6, # Micro (same as µ) "n": 1e-9, # Nano "p": 1e-12, # Pico "f": 1e-15, # Femto "a": 1e-18, # Atto "z": 1e-21, # Zepto "y": 1e-24, # Yocto "r": 1e-27, # Ronto "q": 1e-30, # Quecto } def float_with_unit(quantity, regex_suffix, optional_unit=False): pattern = re.compile( f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE ) def validator(value): if optional_unit: try: return float_(value) except Invalid: pass match = pattern.match(string(value)) if match is None: raise Invalid(f"Expected {quantity} with unit, got {value}") mantissa = float(match.group(1)) if match.group(2) not in METRIC_SUFFIXES: raise Invalid(f"Invalid {quantity} suffix {match.group(2)}") multiplier = METRIC_SUFFIXES[match.group(2)] return mantissa * multiplier return validator bps = float_with_unit("bits per second", "(bps|bits/s|bit/s)?") frequency = float_with_unit("frequency", "(Hz|HZ|hz)?") resistance = float_with_unit("resistance", "(Ω|Ω|ohm|Ohm|OHM)?") current = float_with_unit("current", "(a|A|amp|Amp|amps|Amps|ampere|Ampere)?") voltage = float_with_unit("voltage", "(v|V|volt|Volts)?") distance = float_with_unit("distance", "(m)") framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)") angle = float_with_unit("angle", "(°|deg)", optional_unit=True) _temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?") _temperature_k = float_with_unit("temperature", "(°K|° K|K)?") _temperature_f = float_with_unit("temperature", "(°F|° F|F)?") decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True) pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True) def temperature(value): err = None try: return _temperature_c(value) except Invalid as orig_err: err = orig_err try: kelvin = _temperature_k(value) return kelvin - 273.15 except Invalid: pass try: fahrenheit = _temperature_f(value) return (fahrenheit - 32) * (5 / 9) except Invalid: pass raise err def temperature_delta(value): err = None try: return _temperature_c(value) except Invalid as orig_err: err = orig_err try: return _temperature_k(value) except Invalid: pass try: fahrenheit = _temperature_f(value) return fahrenheit * (5 / 9) except Invalid: pass raise err _color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)") _color_temperature_kelvin = float_with_unit("Color Temperature", r"(K|Kelvin)") def color_temperature(value): try: val = _color_temperature_mireds(value) except Invalid: val = 1000000.0 / _color_temperature_kelvin(value) if val < 0: raise Invalid("Color temperature cannot be negative") return val def validate_bytes(value): value = string(value) match = re.match(r"^([0-9]+)\s*(\w*?)(?:byte|B|b)?s?$", value) if match is None: raise Invalid(f"Expected number of bytes with unit, got {value}") mantissa = int(match.group(1)) if match.group(2) not in METRIC_SUFFIXES: raise Invalid(f"Invalid metric suffix {match.group(2)}") multiplier = METRIC_SUFFIXES[match.group(2)] if multiplier < 1: raise Invalid( f"Only suffixes with positive exponents are supported. Got {match.group(2)}" ) return int(mantissa * multiplier) def hostname(value): """Validate that the value is a valid hostname. Maximum length is 63 characters per RFC 1035. Note: If this limit is changed, update MAX_NAME_WITH_SUFFIX_SIZE in esphome/core/helpers.h to accommodate the new maximum length. """ value = string(value) if re.match(r"^[a-z0-9-]{1,63}$", value, re.IGNORECASE) is not None: return value raise Invalid(f"Invalid hostname: {value}") def domain(value): value = string(value) if re.match(vol.DOMAIN_REGEX, value) is not None: return value try: return str(ipaddress(value)) except Invalid as err: raise Invalid(f"Invalid domain: {value}") from err def domain_name(value): value = string_strict(value) if not value: return value if not value.startswith("."): raise Invalid("Domain name must start with .") if value.startswith(".."): raise Invalid("Domain name must start with single .") for c in value: if not (c.isalnum() or c in "._-"): raise Invalid( "Domain name can only have alphanumeric characters and _ or -" ) return value def ssid(value): value = string_strict(value) if not value: raise Invalid("SSID can't be empty.") if len(value) > 32: raise Invalid("SSID can't be longer than 32 characters") return value def ipv4address(value): try: address = IPv4Address(value) except AddressValueError as exc: raise Invalid(f"{value} is not a valid IPv4 address") from exc return address def ipv6address(value): try: address = IPv6Address(value) except AddressValueError as exc: raise Invalid(f"{value} is not a valid IPv6 address") from exc return address def ipv4address_multi_broadcast(value): address = ipv4address(value) if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))): raise Invalid(f"{value} is not a multicast address nor local broadcast address") return address def ipaddress(value): try: address = ip_address(value) except ValueError as exc: raise Invalid(f"{value} is not a valid IP address") from exc return address def ipv4network(value): """Validate that the value is a valid IPv4 network.""" try: network = IPv4Network(value, strict=False) except ValueError as exc: raise Invalid(f"{value} is not a valid IPv4 network") from exc return network def ipv6network(value): """Validate that the value is a valid IPv6 network.""" try: network = IPv6Network(value, strict=False) except ValueError as exc: raise Invalid(f"{value} is not a valid IPv6 network") from exc return network def ipnetwork(value): """Validate that the value is a valid IP network.""" try: network = ip_network(value, strict=False) except ValueError as exc: raise Invalid(f"{value} is not a valid IP network") from exc return network def _valid_topic(value): """Validate that this is a valid topic name/filter.""" if value is None: # Used to disable publishing and subscribing return "" if isinstance(value, dict): raise Invalid("Can't use dictionary with topic") value = string(value) try: raw_value = value.encode("utf-8") except UnicodeError as err: raise Invalid("MQTT topic name/filter must be valid UTF-8 string.") from err if not raw_value: raise Invalid("MQTT topic name/filter must not be empty.") if len(raw_value) > 65535: raise Invalid( "MQTT topic name/filter must not be longer than 65535 encoded bytes." ) if "\0" in value: raise Invalid("MQTT topic name/filter must not contain null character.") return value def subscribe_topic(value): """Validate that we can subscribe using this MQTT topic.""" value = _valid_topic(value) for i in (i for i, c in enumerate(value) if c == "+"): if (i > 0 and value[i - 1] != "/") or ( i < len(value) - 1 and value[i + 1] != "/" ): raise Invalid( "Single-level wildcard must occupy an entire level of the filter" ) index = value.find("#") if index != -1: if index != len(value) - 1: # If there are multiple wildcards, this will also trigger raise Invalid( "Multi-level wildcard must be the last character in the topic filter." ) if len(value) > 1 and value[index - 1] != "/": raise Invalid("Multi-level wildcard must be after a topic level separator.") return value def publish_topic(value): """Validate that we can publish using this MQTT topic.""" value = _valid_topic(value) if "+" in value or "#" in value: raise Invalid("Wildcards can not be used in topic names") return value def mqtt_payload(value): if value is None: return "" return string(value) def mqtt_qos(value): try: value = int(value) except (TypeError, ValueError): raise Invalid(f"MQTT Quality of Service must be integer, got {value}") from None return one_of(0, 1, 2)(value) def requires_component(comp): """Validate that this option can only be specified when the component `comp` is loaded.""" def validator(value): if comp not in CORE.loaded_integrations: raise Invalid(f"This option requires component {comp}") return value return validator def conflicts_with_component(comp): """Validate that this option cannot be specified when the component `comp` is loaded.""" def validator(value): if comp in CORE.loaded_integrations: raise Invalid(f"This option is not compatible with component {comp}") return value return validator uint8_t = int_range(min=0, max=255) uint16_t = int_range(min=0, max=65535) uint32_t = int_range(min=0, max=4294967295) uint64_t = int_range(min=0, max=18446744073709551615) hex_uint8_t = hex_int_range(min=0, max=255) hex_uint16_t = hex_int_range(min=0, max=65535) hex_uint32_t = hex_int_range(min=0, max=4294967295) hex_uint64_t = hex_int_range(min=0, max=18446744073709551615) i2c_address = hex_uint8_t def percentage(value: object) -> float: """Validate that the value is a percentage. The resulting value is a float in the range 0.0 to 1.0. """ value = _parse_percentage(value) return zero_to_one_float(value) def possibly_negative_percentage(value: object) -> float: """Validate that the value is a possibly negative percentage. The resulting value is a float in the range -1.0 to 1.0. """ value = _parse_percentage(value) return negative_one_to_one_float(value) def unbounded_percentage(value: object) -> float: """Validate that the value is a percentage, allowing values above 100%. The resulting value is a non-negative float with no upper bound. For example, "150%" returns 1.5 and "50%" returns 0.5. """ value = _parse_percentage(value) if value < 0: raise Invalid("Percentage must not be negative") return value def unbounded_possibly_negative_percentage(value: object) -> float: """Validate that the value is a possibly negative percentage without bounds. The resulting value is an unbounded float. For example, "200%" returns 2.0 and "-150%" returns -1.5. """ return _parse_percentage(value) def _parse_percentage(value: object) -> float: """Parse a percentage string or number into a float. Handles both "50%" style strings and raw float values. Values without a percent sign above 1.0 or below -1.0 are rejected to prevent user mistakes (e.g. writing 50 instead of 50%). """ has_percent_sign: bool = False if isinstance(value, str): try: if value.endswith("%"): has_percent_sign = True value = float(value[:-1].rstrip()) / 100.0 else: value = float(value) except ValueError: raise Invalid("invalid number") from None try: if not has_percent_sign and (value > 1 or value < -1): raise Invalid( "Percentage value must use a percent sign for values " "outside -1.0 to 1.0. Please put a percent sign after the number!" ) except TypeError: raise Invalid("Expected percentage or float") from None return float(value) def percentage_int(value): if isinstance(value, str) and value.endswith("%"): value = int(value[:-1].rstrip()) return value def invalid(message): """Mark this value as invalid. Each time *any* value is passed here it will result in a validation error with the given message. """ def validator(value): raise Invalid(message) return validator def valid(value): """A validator that is always valid and returns the value as-is.""" return value @contextmanager def prepend_path(path): """A contextmanager helper to prepend a path to all voluptuous errors.""" if not isinstance(path, (list, tuple)): path = [path] try: yield except vol.Invalid as e: e.prepend(path) raise e @contextmanager def remove_prepend_path(path): """A contextmanager helper to remove a path from a voluptuous error.""" if not isinstance(path, (list, tuple)): path = [path] try: yield except vol.Invalid as e: if list_starts_with(e.path, path): # Can't set e.path (namedtuple for _ in range(len(path)): e.path.pop(0) raise e def one_of(*values, **kwargs): """Validate that the config option is one of the given values. :param values: The valid values for this type :Keyword Arguments: - *lower* (``bool``, default=False): Whether to convert the incoming values to lowercase strings. - *upper* (``bool``, default=False): Whether to convert the incoming values to uppercase strings. - *int* (``bool``, default=False): Whether to convert the incoming values to integers. - *float* (``bool``, default=False): Whether to convert the incoming values to floats. - *space* (``str``, default=' '): What to convert spaces in the input string to. - *underscore* (``str``, default='_'): What to convert underscores in the input string to. - *hyphen* (``str``, default='-'): What to convert hyphens in the input string to. """ options = ", ".join(f"'{x}'" for x in values) lower = kwargs.pop("lower", False) upper = kwargs.pop("upper", False) string_ = kwargs.pop("string", False) or lower or upper to_int = kwargs.pop("int", False) to_float = kwargs.pop("float", False) space = kwargs.pop("space", " ") underscore = kwargs.pop("underscore", "_") hyphen = kwargs.pop("hyphen", "-") if kwargs: raise ValueError separators = str.maketrans({" ": space, "_": underscore, "-": hyphen}) @schema_extractor("one_of") def validator(value): if value == SCHEMA_EXTRACT: return values if string_: value = string(value) value = value.translate(separators) if to_int: value = int_(value) if to_float: value = float_(value) if lower: value = Lower(value) if upper: value = Upper(value) if value not in values: import difflib options_ = [str(x) for x in values] option = str(value) matches = difflib.get_close_matches(option, options_) if matches: matches_str = ", ".join(f"'{x}'" for x in matches) raise Invalid(f"Unknown value '{value}', did you mean {matches_str}?") raise Invalid(f"Unknown value '{value}', valid options are {options}.") return value return validator def enum(mapping, **kwargs): """Validate this config option against an enum mapping. The mapping should be a dictionary with the key representing the config value name and a value representing the expression to set during code generation. Accepts all kwargs of one_of. """ assert isinstance(mapping, dict) one_of_validator = one_of(*mapping, **kwargs) @schema_extractor("enum") def validator(value): if value == SCHEMA_EXTRACT: return mapping value = one_of_validator(value) value = add_class_to_obj(value, core.EnumValue) value.enum_value = mapping[value] return value return validator LAMBDA_ENTITY_ID_PROG = re.compile(r"\Wid\(\s*([a-zA-Z0-9_]+\.[.a-zA-Z0-9_]+)\s*\)") def lambda_(value): """Coerce this configuration option to a lambda.""" if not isinstance(value, Lambda): value = make_data_base(Lambda(string_strict(value)), value) entity_id_parts = re.split(LAMBDA_ENTITY_ID_PROG, value.value) if len(entity_id_parts) != 1: entity_ids = " ".join( f"'{entity_id_parts[i]}'" for i in range(1, len(entity_id_parts), 2) ) raise Invalid( f"Lambda contains reference to entity-id-style ID {entity_ids}. The id() wrapper only works for ESPHome-internal types. For importing states from Home Assistant use the 'homeassistant' sensor platforms." ) return value # 'return' at a statement boundary; only consulted when the source has no # semicolon, so ';' is not a boundary. Migration use only, see # looks_like_returning_lambda. LAMBDA_RETURN_STATEMENT_PROG = re.compile(r"(?:^|[:{})\n])\s*return\b") LAMBDA_RETURN_KEYWORD_PROG = re.compile(r"\breturn\b") # RESERVED_IDS subset that can begin a return expression; 'this'/'true' would # promote prose and infix 'and'/'or' cannot start an expression. _CPP_LEADING_WORD_OPERATORS = "not|new|sizeof|delete" # Two or more plain words: prose, not C++. A single word is indistinguishable # from 'return x'. Migration use only, see looks_like_returning_lambda. LAMBDA_PROSE_TAIL_PROG = re.compile( rf"(?!(?:{_CPP_LEADING_WORD_OPERATORS})\b)[A-Za-z']+(?:,?\s+[A-Za-z']+)+[.!?]?" ) def looks_like_returning_lambda(value: str) -> bool: """Check whether a string looks like C++ lambda source: a semicolon means code, so any return keyword counts; without one, a boundary return whose tail does not read as prose is a return statement missing its semicolon. For migrating deprecated implicit lambdas only; new validators must require an explicit !lambda tag instead of guessing. """ src = Lambda.comment_remover(value) if ";" in src: return LAMBDA_RETURN_KEYWORD_PROG.search(src) is not None for match in LAMBDA_RETURN_STATEMENT_PROG.finditer(src): tail = src[match.end() :].split("\n", 1)[0].strip() if not LAMBDA_PROSE_TAIL_PROG.fullmatch(tail): return True return False def returning_lambda(value): """Coerce this configuration option to a lambda. Additionally, make sure the lambda returns something. """ value = lambda_(value) if LAMBDA_RETURN_KEYWORD_PROG.search(Lambda.comment_remover(value.value)) is None: raise Invalid( "Lambda doesn't contain a 'return' statement, but the lambda " "is expected to return a value. \n" "Please make sure the lambda contains at least one " "return statement." ) return value def dimensions(value): if isinstance(value, list): if len(value) != 2: raise Invalid(f"Dimensions must have a length of two, not {len(value)}") try: width, height = int(value[0]), int(value[1]) except ValueError: raise Invalid("Width and height dimensions must be integers") from None if width <= 0 or height <= 0: raise Invalid("Width and height must at least be 1") return [width, height] if not isinstance(value, str): raise Invalid( "Dimensions must be a string (WIDTHxHEIGHT). Got a number instead, try quoting the value." ) match = re.match(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) if not match: raise Invalid( f"Invalid value '{value}' for dimensions. Only WIDTHxHEIGHT is allowed." ) return dimensions([match.group(1), match.group(2)]) def _remap_bundle_path(value: str) -> Path | None: """Resolve a path from the machine an extracted bundle was created on. An absolute path in a config compiled from an extracted bundle may point at the machine the bundle was created on; the bundle ships the file at its config-relative location instead. """ from esphome.bundle import remap_bundle_path return remap_bundle_path(value) def _declaring_document(value: str) -> Path | None: """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" esp_range = getattr(value, "esp_range", None) if esp_range is None: return None document = Path(esp_range.start_mark.document).absolute() return document if document.is_file() else None def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" path = CORE.relative_config_path(value) if is_kind(path): return path candidates = [path] tried_document: Path | None = None if (document := _declaring_document(value)) is not None: beside_document = document.parent / Path(value).expanduser() if os.path.normpath(beside_document) != os.path.normpath(path): candidates.append(beside_document) tried_document = document if (remapped := _remap_bundle_path(value)) is not None: candidates.append(remapped) for candidate in candidates: if is_kind(candidate): return candidate for candidate in candidates: if candidate.exists(): raise Invalid( f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) also = ( f" Also looked next to {tried_document}." if tried_document is not None else "" ) raise Invalid( f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" ) def directory(value: object) -> Path: return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" def entity_id(value): """Validate that this option represents a valid Home Assistant entity id. Should only be used for 'homeassistant' platforms. """ value = string_strict(value).lower() if value.count(".") != 1: raise Invalid("Entity ID must have exactly one dot in it") for x in value.split("."): for c in x: if c not in ENTITY_ID_CHARACTERS: raise Invalid(f"Invalid character for entity ID: {c}") return value def extract_keys(schema): """Extract the names of the keys from the given schema.""" if isinstance(schema, Schema): schema = schema.schema assert isinstance(schema, dict) keys = [] for skey in list(schema.keys()): if isinstance(skey, str): keys.append(skey) elif isinstance(skey, vol.Marker) and isinstance(skey.schema, str): keys.append(skey.schema) else: raise ValueError keys.sort() return keys @schema_extractor_typed def typed_schema(schemas, **kwargs): """Create a schema that has a key to distinguish between schemas""" key = kwargs.pop("key", CONF_TYPE) default_schema_option = kwargs.pop("default_type", None) enum_mapping = kwargs.pop("enum", None) if enum_mapping is not None: assert isinstance(enum_mapping, dict) assert set(enum_mapping.keys()) == set(schemas.keys()) key_validator = one_of(*schemas, **kwargs) def validator(value): if not isinstance(value, dict): raise Invalid("Value must be dict") value = value.copy() schema_option = value.pop(key, default_schema_option) if schema_option is None: raise Invalid(f"{key} not specified!") key_v = key_validator(schema_option) if enum_mapping is not None: key_v = add_class_to_obj(key_v, core.EnumValue) key_v.enum_value = enum_mapping[key_v] value = Schema(schemas[key_v])(value) value[key] = key_v return value return validator class GenerateID(Optional): """Mark this key as being an auto-generated ID key.""" def __init__(self, key=CONF_ID): super().__init__(key, default=lambda: None) def _get_default_key(*args): return ["_".join([CORE.target_platform] + list(args))] class SplitDefault(Optional): """Mark this key to have a split default per target platform / variant / framework. Defaults are passed as kwargs keyed on the platform identifier; the most specific match wins. Lookup order (first hit wins): 1. ``__`` — e.g. ``esp32_c3_arduino``, ``rp2_2040_arduino`` 2. ``_`` — e.g. ``esp32_c3``, ``rp2_2040`` 3. ``_`` — e.g. ``esp32_arduino``, ``rp2_arduino`` 4. ```` — e.g. ``esp32``, ``rp2`` For ESP32 the variant strips the ``ESP32`` prefix from :data:`esp32.VARIANT_*` constants (``ESP32C3`` → ``c3``). For RP2 the variant strips just ``RP`` (``RP2040`` → ``2040``, ``RP2350`` → ``2350``) so kwargs read naturally — `rp2_2040=...` is the override for the Pico / Pico W and `rp2_2350=...` is the override for the Pico 2. """ def __init__(self, key, **kwargs): super().__init__(key) self._defaults = {} for platform_key, value in kwargs.items(): self._defaults[platform_key] = vol.default_factory(value) @property def default(self): keys = [] if CORE.is_esp32: from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() framework = CORE.target_framework.replace("esp-", "") if variant: keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) elif CORE.is_rp2: # Strip the "RP" prefix to leave the chip number, mirroring # the ESP32 "platform stripped from variant" convention so # kwargs stay short (``rp2_2040`` rather than ``rp2_rp2040``). # Variant lookup is defensive: validators may run before the # rp2 component's ``set_core_data`` (or in tests that wire a # partial ``CORE.data``); in that case we just skip the # variant-specific keys and fall through to the base # platform/framework defaults. raw_variant = CORE.data.get("rp2", {}).get("variant") framework = CORE.target_framework if raw_variant: variant = raw_variant.removeprefix("RP").lower() keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) keys += _get_default_key() for key in keys: if self._defaults.get(key) is not None: return self._defaults[key] return vol.default_factory(vol.UNDEFINED) @default.setter def default(self, value): # Ignore default set from vol.Optional pass class OnlyWith(Optional): """Set the default value only if the given component(s) is/are loaded. This validator allows configuration keys to have defaults that are only applied when specific component(s) are loaded. Supports both single component names and lists of components. Args: key: Configuration key component: Single component name (str) or list of component names. For lists, ALL components must be loaded for the default to apply. default: Default value to use when condition is met Example: # Single component cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(MQTTComponent) # Multiple components (all must be loaded) cv.OnlyWith(CONF_ZIGBEE_ID, ["zigbee", "nrf52"]): cv.use_id(Zigbee) """ def __init__(self, key, component: str | list[str], default=None) -> None: super().__init__(key) self._component = component self._default = vol.default_factory(default) @property def default(self) -> Callable[[], typing.Any] | vol.Undefined: if isinstance(self._component, list): if all(c in CORE.loaded_integrations for c in self._component): return self._default elif self._component in CORE.loaded_integrations: return self._default return vol.UNDEFINED @default.setter def default(self, value): # Ignore default set from vol.Optional pass class OnlyWithout(Optional): """Set the default value only if the given component is NOT loaded.""" def __init__(self, key, component, default=None): super().__init__(key) self._component = component self._default = vol.default_factory(default) @property def default(self): if self._component not in CORE.loaded_integrations: return self._default return vol.UNDEFINED @default.setter def default(self, value): # Ignore default set from vol.Optional pass def _entity_base_validator(config): if CONF_NAME not in config and CONF_ID not in config: raise Invalid("At least one of 'id:' or 'name:' is required!") if CONF_NAME not in config: id = config[CONF_ID] if not id.is_manual: raise Invalid("At least one of 'id:' or 'name:' is required!") config[CONF_NAME] = id.id config[CONF_INTERNAL] = True return config if config[CONF_NAME] is None: config[CONF_NAME] = "" return config def ensure_schema(schema): if not isinstance(schema, vol.Schema): return Schema(schema) return schema def validate_registry_entry(name, registry): base_schema = ensure_schema(registry.base_schema).extend( { Optional(CONF_TYPE_ID): valid, }, extra=ALLOW_EXTRA, ) ignore_keys = extract_keys(base_schema) @schema_extractor_registry(registry) def validator(value): if isinstance(value, str): value = {value: {}} if not isinstance(value, dict): raise Invalid( f"{name.title()} must consist of key-value mapping! Got {value}" ) key = next((x for x in value if x not in ignore_keys), None) if key is None: raise Invalid(f"Key missing from {name}! Got {value}") if key not in registry: raise Invalid(f"Unable to find {name} with the name '{key}'", [key]) key2 = next((x for x in value if x != key and x not in ignore_keys), None) if key2 is not None: raise Invalid( f"Cannot have two {name}s in one item. Key '{key}' overrides '{key2}'! " f"Did you forget to indent the block inside the {key}?" ) if value[key] is None: value[key] = {} registry_entry = registry[key] value = value.copy() with prepend_path([key]): value[key] = registry_entry.schema(value[key]) if registry_entry.type_id is not None: my_base_schema = base_schema.extend( {GenerateID(CONF_TYPE_ID): declare_id(registry_entry.type_id)} ) value = my_base_schema(value) return value return validator def none(value): if value in ("none", "None"): return raise Invalid("Must be none") def requires_friendly_name(message): def validate(value): if CORE.friendly_name is None: raise Invalid(message) return value return validate def validate_registry(name, registry): return ensure_list(validate_registry_entry(name, registry)) def maybe_simple_value(*validators, **kwargs): key = kwargs.pop("key", CONF_VALUE) validator = All(*validators) @schema_extractor("maybe") def validate(value): if value == SCHEMA_EXTRACT: return (validator, key) if isinstance(value, dict) and key in value: return validator(value) return validator({key: value}) validate.inner_schema = validator return validate ENTITY_CATEGORIES = { ENTITY_CATEGORY_NONE: cg.EntityCategory.ENTITY_CATEGORY_NONE, ENTITY_CATEGORY_CONFIG: cg.EntityCategory.ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC: cg.EntityCategory.ENTITY_CATEGORY_DIAGNOSTIC, } def entity_category(value): return enum(ENTITY_CATEGORIES, lower=True)(value) MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( { Required(CONF_TOPIC): subscribe_topic, Optional(CONF_PAYLOAD_AVAILABLE, default="online"): mqtt_payload, Optional(CONF_PAYLOAD_NOT_AVAILABLE, default="offline"): mqtt_payload, } ) # Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { 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, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } ) MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), boolean ), } ) # Unicode FRACTION SLASH (U+2044) - visually similar to '/' but URL-safe FRACTION_SLASH = "\u2044" def _validate_no_slash(value): """Validate that a name does not contain '/' characters. The '/' character is used as a path separator in web server URLs, so it cannot be used in entity or device names. During the deprecation period, '/' is automatically replaced with the visually similar Unicode FRACTION SLASH (U+2044) character. """ if "/" in value: # Remove before 2027.7.0 new_value = value.replace("/", FRACTION_SLASH) _LOGGER.warning( "'%s' contains '/' which is reserved as a URL path separator. " "Automatically replacing with '%s' (Unicode FRACTION SLASH). " "Please update your configuration. " "This will become an error in ESPHome 2027.7.0.", value, new_value, ) return new_value return value # Maximum length for entity, device, and area names # This ensures web server URL IDs fit in a 280-byte buffer: # domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes # Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields NAME_MAX_LENGTH = 120 def _validate_entity_name(value): value = string(value) try: value = none(value) # pylint: disable=assignment-from-none except Invalid: pass else: requires_friendly_name( "Name cannot be None when esphome->friendly_name is not set!" )(value) if value is not None: # Validate byte length for web server URL and proto encoding compatibility byte_len = len(value.encode("utf-8")) if byte_len > NAME_MAX_LENGTH: raise Invalid( f"Name is too long ({byte_len} bytes). " f"Maximum length is {NAME_MAX_LENGTH} bytes." ) # Validate no '/' in name for web server URL compatibility value = _validate_no_slash(value) return value def string_no_slash(value): """Validate a string that cannot contain '/' characters. Used for device and area names where '/' is reserved as a URL path separator. Use with cv.Length() to also enforce maximum length. """ value = string(value) return _validate_no_slash(value) ENTITY_BASE_SCHEMA = Schema( { # 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, } ) ENTITY_BASE_SCHEMA.add_extra(_entity_base_validator) 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, *, 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, } ) assert isinstance(default_update_interval, str) return COMPONENT_SCHEMA.extend( { Optional( CONF_UPDATE_INTERVAL, default=default_update_interval, visibility=visibility, ): update_interval, } ) def url(value): import urllib.parse value = string_strict(value) try: parsed = urllib.parse.urlparse(value) except ValueError as e: raise Invalid("Not a valid URL") from e if parsed.scheme and parsed.netloc or parsed.scheme == "file": return parsed.geturl() raise Invalid("Expected a file scheme or a URL scheme with host") def git_ref(value): if re.match(r"[a-zA-Z0-9_./-]+", value) is None: raise Invalid("Not a valid git ref") return value # What `refresh: never` validates to; also used to recognize a disabled # refresh when logging (see esphome/git.py) SOURCE_REFRESH_NEVER = "365250d" def source_refresh(value: str): if value.lower() == "always": return source_refresh("0s") if value.lower() == "never": return source_refresh(SOURCE_REFRESH_NEVER) return positive_time_period_seconds(value) def version_number(value): value = string_strict(value) try: return str(Version.parse(value)) except ValueError as e: raise Invalid("Not a valid version number") from e def validate_esphome_version(value: str): min_version = Version.parse(value) current_version = Version.parse(ESPHOME_VERSION) if current_version < min_version: raise Invalid( f"Your ESPHome version is too old. Please update to at least {min_version}" ) return value def platformio_version_constraint(value): # for documentation on valid version constraints: # https://docs.platformio.org/en/latest/core/userguide/platforms/cmd_install.html#cmd-platform-install value = string_strict(value) constraints = [] for item in value.split(","): # find and strip prefix operator op = None for test_op in ("^", "~", ">=", ">", "<=", "<", "!="): if item.startswith(test_op): op = test_op item = item[len(test_op) :] break constraints.append((op, version_number(item))) return constraints def _check_supported_toolchain( platform_name: str, supported: tuple[Toolchain, ...] ) -> None: """Raise when the resolved ``CORE.toolchain`` is not in ``supported`` (one message shape for every platform).""" toolchain = CORE.toolchain if toolchain is None: # A caller ran the check before resolving; an ordering bug, not a # user error raise Invalid(f"Toolchain was not resolved before {platform_name} validation") if toolchain not in supported: names = ", ".join(f"'{tc.value}'" for tc in supported) raise Invalid( f"Unsupported toolchain " f"'{toolchain.value}' for " f"{platform_name}. Supported: {names}." ) def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]: """Schema validator for a platform's ``toolchain`` config key.""" def validator(value: str) -> Toolchain: return Toolchain(one_of(*supported, lower=True)(value)) return validator def resolve_toolchain( platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain ) -> Callable[[ConfigType], ConfigType]: """Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the platform cannot serve. Add to the platform's validation chain before anything that reads ``CORE.toolchain``. """ def validator(config: ConfigType) -> ConfigType: if CORE.toolchain is None: CORE.toolchain = config.get(CONF_TOOLCHAIN, default) _check_supported_toolchain(platform_name, supported) return config return validator def require_platformio_toolchain( platform_name: str, ) -> Callable[[ConfigType], ConfigType]: """Reject a CLI-selected toolchain other than PlatformIO, for platforms with only the PlatformIO backend.""" return resolve_toolchain( platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO ) def require_framework_version( *, max_version=False, extra_message=None, **kwargs, ): """Constrain the configured framework version per target platform / variant. Kwargs are keyed by ``_`` (e.g. ``esp32_arduino``, ``rp2_arduino``) with optional variant-specific overrides keyed by ``__`` (e.g. ``esp32_c3_arduino``, ``rp2_2040_arduino``, ``rp2_2350_arduino``). Variant overrides win when the configured variant matches; otherwise the base platform key is used. Special cases: ``host`` (with host framework) and ``esp_idf`` (any ESP32 on ESP-IDF) bypass variant lookup. """ def validator(value): core_data = CORE.data[KEY_CORE] framework = core_data[KEY_TARGET_FRAMEWORK] keys_to_try: list[str] = [] if CORE.is_host and framework == "host": keys_to_try.append("host") elif framework == "esp-idf": keys_to_try.append("esp_idf") else: # Try variant-specific key first (mirrors the SplitDefault # precedence). ESP32 strips its platform prefix from variant # constants; RP2 strips just ``RP`` to keep chip-number kwargs # (``rp2_2040``, ``rp2_2350``). if CORE.is_esp32: from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant # Guard against tests that wire CORE.data without an # esp32 variant block; same defensive intent as the rp2 # branch below. try: variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() except (KeyError, AttributeError): variant = "" if variant: keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") elif CORE.is_rp2: # Defensive lookup — see the matching block in # ``SplitDefault.default``: the rp2 component's # ``set_core_data`` may not have populated # ``CORE.data["rp2"]["variant"]`` yet (validators run # during schema validation, before code-gen). raw_variant = CORE.data.get("rp2", {}).get("variant") if raw_variant: variant = raw_variant.removeprefix("RP").lower() keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") keys_to_try.append(f"{CORE.target_platform}_{framework}") key = next((k for k in keys_to_try if k in kwargs), None) if key is None: msg = f"This feature is incompatible with {CORE.target_platform.upper()} using {framework} framework" if extra_message: msg += f". {extra_message}" raise Invalid(msg) required = kwargs[key] if max_version: if core_data[KEY_FRAMEWORK_VERSION] > required: msg = f"This feature requires framework version {required} or lower" if extra_message: msg += f". {extra_message}" raise Invalid(msg) return value if core_data[KEY_FRAMEWORK_VERSION] < required: msg = f"This feature requires at least framework version {required}" if extra_message: msg += f". {extra_message}" raise Invalid(msg) return value return validator def require_esphome_version( year: Version | int, month: int | None = None, patch: int | None = None ): """Validator requiring at least the given ESPHome version. Accepts a single ``Version`` like the sibling ``require_framework_version``, or the legacy ``(year, month, patch)`` ints external components already pass. """ if isinstance(year, Version): required = year elif month is None or patch is None: raise ValueError( "require_esphome_version needs a Version or (year, month, patch)" ) else: required = Version(year, month, patch) def validator(value): # A dev or beta build of the required version still satisfies it, # matching the old tuple comparison that dropped the suffix. if Version.parse(ESPHOME_VERSION) < required: raise Invalid( f"This component requires at least ESPHome version {required}" ) return value return validator @contextmanager def suppress_invalid(): with suppress(vol.Invalid): yield GIT_SCHEMA = Schema( { Required(CONF_URL): url, Optional(CONF_REF): git_ref, Optional(CONF_USERNAME): string, Optional(CONF_PASSWORD): string, Optional(CONF_PATH): string, } ) LOCAL_SCHEMA = Schema( { Required(CONF_PATH): directory, } ) def validate_source_shorthand(value): if not isinstance(value, str): raise Invalid("Shorthand only for strings") try: return SOURCE_SCHEMA({CONF_TYPE: TYPE_LOCAL, CONF_PATH: value}) except Invalid: pass # Regex for GitHub repo name with optional branch/tag # Note: git allows other branch/tag names as well, but never seen them used before m = re.match( r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))", value, ) if m is None: raise Invalid( "Source is not a file system path, in expected github://username/name[@branch-or-tag] or github://pr#1234 format!" ) if m.group(4): conf = { CONF_TYPE: TYPE_GIT, CONF_URL: "https://github.com/esphome/esphome.git", CONF_REF: f"pull/{m.group(4)}/head", } else: conf = { CONF_TYPE: TYPE_GIT, CONF_URL: f"https://github.com/{m.group(1)}/{m.group(2)}.git", } if m.group(3): conf[CONF_REF] = m.group(3) return SOURCE_SCHEMA(conf) SOURCE_SCHEMA = Any( validate_source_shorthand, typed_schema( { TYPE_GIT: GIT_SCHEMA, TYPE_LOCAL: LOCAL_SCHEMA, } ), ) def rename_key( old_key, new_key, *, removed_in: str | None = None, component: str | None = None ): """Rename a config key from ``old_key`` to ``new_key``. Specifying both keys is an error; otherwise only one of the two would survive the rename and the other would be dropped silently. When ``removed_in`` is set, a deprecation warning is logged if the old key is present. Pass ``component`` (the platform/component name) alongside ``removed_in`` so the warning identifies where it originates. Input that is not a dictionary is returned unchanged, so the schema that follows reports it as a normal configuration error. """ def validator(config: dict) -> dict: if not isinstance(config, dict): return config config = config.copy() if old_key in config: has_at_most_one_key(old_key, new_key)(config) if removed_in is not None: prefix = f"[{component}] " if component else "" _LOGGER.warning( "%s'%s' is deprecated, use '%s'. Will be removed in %s", prefix, old_key, new_key, removed_in, ) config[new_key] = config.pop(old_key) return config return validator