mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	Merge branch 'improve_entity_validation_message' into integration
This commit is contained in:
		| @@ -596,7 +596,7 @@ async def throttle_filter_to_code(config, filter_id): | ||||
|     return cg.new_Pvariable(filter_id, config) | ||||
|  | ||||
|  | ||||
| TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( | ||||
| THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( | ||||
|     { | ||||
|         cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, | ||||
|         cv.Optional(CONF_VALUE, default="nan"): cv.Any( | ||||
| @@ -610,7 +610,7 @@ TIMEOUT_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( | ||||
| @FILTER_REGISTRY.register( | ||||
|     "throttle_with_priority", | ||||
|     ThrottleWithPriorityFilter, | ||||
|     TIMEOUT_WITH_PRIORITY_SCHEMA, | ||||
|     THROTTLE_WITH_PRIORITY_SCHEMA, | ||||
| ) | ||||
| async def throttle_with_priority_filter_to_code(config, filter_id): | ||||
|     if not isinstance(config[CONF_VALUE], list): | ||||
| @@ -631,7 +631,9 @@ async def heartbeat_filter_to_code(config, filter_id): | ||||
| TIMEOUT_SCHEMA = cv.maybe_simple_value( | ||||
|     { | ||||
|         cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds, | ||||
|         cv.Optional(CONF_VALUE, default="nan"): cv.templatable(cv.float_), | ||||
|         cv.Optional(CONF_VALUE, default="nan"): cv.Any( | ||||
|             "last", cv.templatable(cv.float_) | ||||
|         ), | ||||
|     }, | ||||
|     key=CONF_TIMEOUT, | ||||
| ) | ||||
| @@ -639,8 +641,11 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value( | ||||
|  | ||||
| @FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA) | ||||
| async def timeout_filter_to_code(config, filter_id): | ||||
|     template_ = await cg.templatable(config[CONF_VALUE], [], float) | ||||
|     var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) | ||||
|     if config[CONF_VALUE] == "last": | ||||
|         var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) | ||||
|     else: | ||||
|         template_ = await cg.templatable(config[CONF_VALUE], [], float) | ||||
|         var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) | ||||
|     await cg.register_component(var, {}) | ||||
|     return var | ||||
|  | ||||
|   | ||||
| @@ -417,12 +417,17 @@ void OrFilter::initialize(Sensor *parent, Filter *next) { | ||||
|  | ||||
| // TimeoutFilter | ||||
| optional<float> TimeoutFilter::new_value(float value) { | ||||
|   this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value()); }); | ||||
|   if (this->value_.has_value()) { | ||||
|     this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); }); | ||||
|   } else { | ||||
|     this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); }); | ||||
|   } | ||||
|   return value; | ||||
| } | ||||
|  | ||||
| TimeoutFilter::TimeoutFilter(uint32_t time_period, TemplatableValue<float> new_value) | ||||
|     : time_period_(time_period), value_(std::move(new_value)) {} | ||||
| TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {} | ||||
| TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue<float> &new_value) | ||||
|     : time_period_(time_period), value_(new_value) {} | ||||
| float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; } | ||||
|  | ||||
| // DebounceFilter | ||||
|   | ||||
| @@ -330,7 +330,8 @@ class ThrottleWithPriorityFilter : public Filter { | ||||
|  | ||||
| class TimeoutFilter : public Filter, public Component { | ||||
|  public: | ||||
|   explicit TimeoutFilter(uint32_t time_period, TemplatableValue<float> new_value); | ||||
|   explicit TimeoutFilter(uint32_t time_period); | ||||
|   explicit TimeoutFilter(uint32_t time_period, const TemplatableValue<float> &new_value); | ||||
|  | ||||
|   optional<float> new_value(float value) override; | ||||
|  | ||||
| @@ -338,7 +339,7 @@ class TimeoutFilter : public Filter, public Component { | ||||
|  | ||||
|  protected: | ||||
|   uint32_t time_period_; | ||||
|   TemplatableValue<float> value_; | ||||
|   optional<TemplatableValue<float>> value_; | ||||
| }; | ||||
|  | ||||
| class DebounceFilter : public Filter, public Component { | ||||
|   | ||||
| @@ -627,13 +627,15 @@ class SchemaValidationStep(ConfigValidationStep): | ||||
|     def __init__( | ||||
|         self, domain: str, path: ConfigPath, conf: ConfigType, comp: ComponentManifest | ||||
|     ): | ||||
|         self.domain = domain | ||||
|         self.path = path | ||||
|         self.conf = conf | ||||
|         self.comp = comp | ||||
|  | ||||
|     def run(self, result: Config) -> None: | ||||
|         token = path_context.set(self.path) | ||||
|         with result.catch_error(self.path): | ||||
|         # The domain already contains the full component path (e.g., "sensor.template", "sensor.uptime") | ||||
|         with CORE.component_context(self.domain), result.catch_error(self.path): | ||||
|             if self.comp.is_platform: | ||||
|                 # Remove 'platform' key for validation | ||||
|                 input_conf = OrderedDict(self.conf) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| from collections import defaultdict | ||||
| from contextlib import contextmanager | ||||
| import logging | ||||
| import math | ||||
| import os | ||||
| @@ -38,7 +39,7 @@ from esphome.util import OrderedDict | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from ..cpp_generator import MockObj, MockObjClass, Statement | ||||
|     from ..types import ConfigType | ||||
|     from ..types import ConfigType, EntityMetadata | ||||
|  | ||||
| _LOGGER = logging.getLogger(__name__) | ||||
|  | ||||
| @@ -571,14 +572,16 @@ class EsphomeCore: | ||||
|         # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count | ||||
|         self.platform_counts: defaultdict[str, int] = defaultdict(int) | ||||
|         # Track entity unique IDs to handle duplicates | ||||
|         # Set of (device_id, platform, sanitized_name) tuples | ||||
|         self.unique_ids: set[tuple[str, str, str]] = set() | ||||
|         # Dict mapping (device_id, platform, sanitized_name) -> entity metadata | ||||
|         self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {} | ||||
|         # Whether ESPHome was started in verbose mode | ||||
|         self.verbose = False | ||||
|         # Whether ESPHome was started in quiet mode | ||||
|         self.quiet = False | ||||
|         # A list of all known ID classes | ||||
|         self.id_classes = {} | ||||
|         # The current component being processed during validation | ||||
|         self.current_component: str | None = None | ||||
|  | ||||
|     def reset(self): | ||||
|         from esphome.pins import PIN_SCHEMA_REGISTRY | ||||
| @@ -604,9 +607,20 @@ class EsphomeCore: | ||||
|         self.loaded_integrations = set() | ||||
|         self.component_ids = set() | ||||
|         self.platform_counts = defaultdict(int) | ||||
|         self.unique_ids = set() | ||||
|         self.unique_ids = {} | ||||
|         self.current_component = None | ||||
|         PIN_SCHEMA_REGISTRY.reset() | ||||
|  | ||||
|     @contextmanager | ||||
|     def component_context(self, component: str): | ||||
|         """Context manager to set the current component being processed.""" | ||||
|         old_component = self.current_component | ||||
|         self.current_component = component | ||||
|         try: | ||||
|             yield | ||||
|         finally: | ||||
|             self.current_component = old_component | ||||
|  | ||||
|     @property | ||||
|     def address(self) -> str | None: | ||||
|         if self.config is None: | ||||
|   | ||||
| @@ -16,7 +16,7 @@ from esphome.core import CORE, ID | ||||
| from esphome.cpp_generator import MockObj, add, get_variable | ||||
| import esphome.final_validate as fv | ||||
| from esphome.helpers import sanitize, snake_case | ||||
| from esphome.types import ConfigType | ||||
| from esphome.types import ConfigType, EntityMetadata | ||||
|  | ||||
| _LOGGER = logging.getLogger(__name__) | ||||
|  | ||||
| @@ -214,14 +214,45 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy | ||||
|         # Check for duplicates | ||||
|         unique_key = (device_id, platform, name_key) | ||||
|         if unique_key in CORE.unique_ids: | ||||
|             # Get the existing entity metadata | ||||
|             existing = CORE.unique_ids[unique_key] | ||||
|             existing_name = existing.get("name", entity_name) | ||||
|             existing_device = existing.get("device_id", "") | ||||
|             existing_id = existing.get("entity_id", "unknown") | ||||
|  | ||||
|             # Build detailed error message | ||||
|             device_prefix = f" on device '{device_id}'" if device_id else "" | ||||
|             existing_device_prefix = ( | ||||
|                 f" on device '{existing_device}'" if existing_device else "" | ||||
|             ) | ||||
|             existing_component = existing.get("component", "unknown") | ||||
|  | ||||
|             # Provide more context about where the duplicate was found | ||||
|             conflict_msg = ( | ||||
|                 f"Conflicts with entity '{existing_name}'{existing_device_prefix}" | ||||
|             ) | ||||
|             if existing_id != "unknown": | ||||
|                 conflict_msg += f" (id: {existing_id})" | ||||
|             if existing_component != "unknown": | ||||
|                 conflict_msg += f" from component '{existing_component}'" | ||||
|  | ||||
|             raise cv.Invalid( | ||||
|                 f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " | ||||
|                 f"{conflict_msg}. " | ||||
|                 f"Each entity on a device must have a unique name within its platform." | ||||
|             ) | ||||
|  | ||||
|         # Add to tracking set | ||||
|         CORE.unique_ids.add(unique_key) | ||||
|         # Store metadata about this entity | ||||
|         entity_metadata: EntityMetadata = { | ||||
|             "name": entity_name, | ||||
|             "device_id": device_id, | ||||
|             "platform": platform, | ||||
|             "entity_id": str(config.get(CONF_ID, "unknown")), | ||||
|             "component": CORE.current_component or "unknown", | ||||
|         } | ||||
|  | ||||
|         # Add to tracking dict | ||||
|         CORE.unique_ids[unique_key] = entity_metadata | ||||
|         return config | ||||
|  | ||||
|     return validator | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| """This helper module tracks commonly used types in the esphome python codebase.""" | ||||
|  | ||||
| from typing import TypedDict | ||||
|  | ||||
| from esphome.core import ID, EsphomeCore, Lambda | ||||
|  | ||||
| ConfigFragmentType = ( | ||||
| @@ -16,3 +18,13 @@ ConfigFragmentType = ( | ||||
| ConfigType = dict[str, ConfigFragmentType] | ||||
| CoreType = EsphomeCore | ||||
| ConfigPathType = str | int | ||||
|  | ||||
|  | ||||
| class EntityMetadata(TypedDict): | ||||
|     """Metadata stored for each entity to help with duplicate detection.""" | ||||
|  | ||||
|     name: str | ||||
|     device_id: str | ||||
|     platform: str | ||||
|     entity_id: str | ||||
|     component: str | ||||
|   | ||||
		Reference in New Issue
	
	Block a user