From 8014cbc71e8f5c1f47a1d04d0c7b1aee9024e047 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 30 Jun 2025 19:25:54 +0100 Subject: [PATCH 1/8] Fixes for async MQTT (#9273) --- esphome/components/mqtt/mqtt_backend_esp32.cpp | 8 ++++++++ esphome/components/mqtt/mqtt_backend_esp32.h | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 62b153e676..4648e66e1d 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -163,12 +163,20 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { case MQTT_EVENT_CONNECTED: ESP_LOGV(TAG, "MQTT_EVENT_CONNECTED"); this->is_connected_ = true; +#if defined(USE_MQTT_IDF_ENQUEUE) + this->last_dropped_log_time_ = 0; + xTaskNotifyGive(this->task_handle_); +#endif this->on_connect_.call(event.session_present); break; case MQTT_EVENT_DISCONNECTED: ESP_LOGV(TAG, "MQTT_EVENT_DISCONNECTED"); // TODO is there a way to get the disconnect reason? this->is_connected_ = false; +#if defined(USE_MQTT_IDF_ENQUEUE) + this->last_dropped_log_time_ = 0; + xTaskNotifyGive(this->task_handle_); +#endif this->on_disconnect_.call(MQTTClientDisconnectReason::TCP_DISCONNECTED); break; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index 57286a24b2..3611caf554 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -116,7 +116,7 @@ struct QueueElement { class MQTTBackendESP32 final : public MQTTBackend { public: static const size_t MQTT_BUFFER_SIZE = 4096; - static const size_t TASK_STACK_SIZE = 2048; + static const size_t TASK_STACK_SIZE = 3072; static const size_t TASK_STACK_SIZE_TLS = 4096; // Larger stack for TLS operations static const ssize_t TASK_PRIORITY = 5; static const uint8_t MQTT_QUEUE_LENGTH = 30; // 30*12 bytes = 360 From 0cbb5e6c1c67a436dcf9606417a18387c025e14d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Jun 2025 15:02:43 -0500 Subject: [PATCH 2/8] Fix flaky test_api_conditional_memory by waiting for all required states (#9271) --- .../integration/test_api_conditional_memory.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index b85e8d91af..8048624f70 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -177,19 +177,22 @@ async def test_api_conditional_memory( async with api_client_connected() as client2: # Subscribe to states with new client states2: dict[int, EntityState] = {} - connected_future: asyncio.Future[None] = loop.create_future() + states_ready_future: asyncio.Future[None] = loop.create_future() def on_state2(state: EntityState) -> None: states2[state.key] = state - # Check for reconnection - if state.key == client_connected.key and state.state is True: - if not connected_future.done(): - connected_future.set_result(None) + # Check if we have received both required states + if ( + client_connected.key in states2 + and client_disconnected_event.key in states2 + and not states_ready_future.done() + ): + states_ready_future.set_result(None) client2.subscribe_states(on_state2) - # Wait for connected state - await asyncio.wait_for(connected_future, timeout=5.0) + # Wait for both connected and disconnected event states + await asyncio.wait_for(states_ready_future, timeout=5.0) # Verify client is connected again (on_client_connected fired) assert states2[client_connected.key].state is True, ( From 98e106e0ae9b30937ff87b5285d61c03fd2ce61a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:09:11 +1200 Subject: [PATCH 3/8] [pins] Update ``internal_gpio_pin_number`` to work directly like ``internal_gpio_output_pin_number`` (#9270) --- esphome/components/i2c/__init__.py | 11 ++--------- esphome/pins.py | 4 +++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index fae1fa1d22..6adb9b71aa 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -9,8 +9,6 @@ from esphome.const import ( CONF_FREQUENCY, CONF_I2C_ID, CONF_ID, - CONF_INPUT, - CONF_OUTPUT, CONF_SCAN, CONF_SCL, CONF_SDA, @@ -73,20 +71,15 @@ def validate_config(config): return config -pin_with_input_and_output_support = pins.internal_gpio_pin_number( - {CONF_OUTPUT: True, CONF_INPUT: True} -) - - CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): _bus_declare_type, - cv.Optional(CONF_SDA, default="SDA"): pin_with_input_and_output_support, + cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), - cv.Optional(CONF_SCL, default="SCL"): pin_with_input_and_output_support, + cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32_idf=True): cv.All( cv.only_with_esp_idf, cv.boolean ), diff --git a/esphome/pins.py b/esphome/pins.py index 0dfd5a245b..4f9b4859a1 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -220,7 +220,9 @@ def gpio_flags_expr(mode): gpio_pin_schema = _schema_creator -internal_gpio_pin_number = _internal_number_creator +internal_gpio_pin_number = _internal_number_creator( + {CONF_OUTPUT: True, CONF_INPUT: True} +) gpio_output_pin_schema = _schema_creator( { CONF_OUTPUT: True, From 78c8cd4c4e4f08c886a3faf44e1e5fa017e5755f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:50:19 +1200 Subject: [PATCH 4/8] [http_request.update] Fix ``size_t`` printing (#9144) --- esphome/components/http_request/update/http_request_update.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 828fb5bd8b..6bc88ae49a 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -57,7 +57,7 @@ void HttpRequestUpdate::update_task(void *params) { RAMAllocator allocator; uint8_t *data = allocator.allocate(container->content_length); if (data == nullptr) { - std::string msg = str_sprintf("Failed to allocate %d bytes for manifest", container->content_length); + std::string msg = str_sprintf("Failed to allocate %zu bytes for manifest", container->content_length); this_update->status_set_error(msg.c_str()); container->end(); UPDATE_RETURN; From 08c88ba0f26d5b5558b4679ca9beba73acc20da3 Mon Sep 17 00:00:00 2001 From: piechade Date: Mon, 30 Jun 2025 22:54:23 +0200 Subject: [PATCH 5/8] [smt100] Rename ``dielectric_constant`` to ``permittivity`` (#9175) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/smt100/sensor.py | 12 ++++++++---- esphome/components/smt100/smt100.cpp | 10 +++++----- esphome/components/smt100/smt100.h | 6 +++--- esphome/const.py | 1 + tests/components/smt100/common.yaml | 4 ++-- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index ea42499379..f877ce2af0 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_DIELECTRIC_CONSTANT, CONF_ID, CONF_MOISTURE, + CONF_PERMITTIVITY, CONF_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_TEMPERATURE, @@ -33,7 +34,10 @@ CONFIG_SCHEMA = ( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_DIELECTRIC_CONSTANT): sensor.sensor_schema( + cv.Optional(CONF_DIELECTRIC_CONSTANT): cv.invalid( + "Use 'permittivity' instead" + ), + cv.Optional(CONF_PERMITTIVITY): sensor.sensor_schema( unit_of_measurement=UNIT_EMPTY, accuracy_decimals=2, state_class=STATE_CLASS_MEASUREMENT, @@ -76,9 +80,9 @@ async def to_code(config): sens = await sensor.new_sensor(config[CONF_COUNTS]) cg.add(var.set_counts_sensor(sens)) - if CONF_DIELECTRIC_CONSTANT in config: - sens = await sensor.new_sensor(config[CONF_DIELECTRIC_CONSTANT]) - cg.add(var.set_dielectric_constant_sensor(sens)) + if CONF_PERMITTIVITY in config: + sens = await sensor.new_sensor(config[CONF_PERMITTIVITY]) + cg.add(var.set_permittivity_sensor(sens)) if CONF_TEMPERATURE in config: sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 24ba05b894..c8dfb4c7bd 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -16,7 +16,7 @@ void SMT100Component::loop() { while (this->available() != 0) { if (readline_(read(), buffer, MAX_LINE_LENGTH) > 0) { int counts = (int) strtol((strtok(buffer, ",")), nullptr, 10); - float dielectric_constant = (float) strtod((strtok(nullptr, ",")), nullptr); + float permittivity = (float) strtod((strtok(nullptr, ",")), nullptr); float moisture = (float) strtod((strtok(nullptr, ",")), nullptr); float temperature = (float) strtod((strtok(nullptr, ",")), nullptr); float voltage = (float) strtod((strtok(nullptr, ",")), nullptr); @@ -25,8 +25,8 @@ void SMT100Component::loop() { counts_sensor_->publish_state(counts); } - if (this->dielectric_constant_sensor_ != nullptr) { - dielectric_constant_sensor_->publish_state(dielectric_constant); + if (this->permittivity_sensor_ != nullptr) { + permittivity_sensor_->publish_state(permittivity); } if (this->moisture_sensor_ != nullptr) { @@ -49,8 +49,8 @@ float SMT100Component::get_setup_priority() const { return setup_priority::DATA; void SMT100Component::dump_config() { ESP_LOGCONFIG(TAG, "SMT100:"); - LOG_SENSOR(TAG, "Counts", this->temperature_sensor_); - LOG_SENSOR(TAG, "Dielectric Constant", this->temperature_sensor_); + LOG_SENSOR(TAG, "Counts", this->counts_sensor_); + LOG_SENSOR(TAG, "Permittivity", this->permittivity_sensor_); LOG_SENSOR(TAG, "Temperature", this->temperature_sensor_); LOG_SENSOR(TAG, "Moisture", this->moisture_sensor_); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index 017818bdcf..86827607dc 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -20,8 +20,8 @@ class SMT100Component : public PollingComponent, public uart::UARTDevice { float get_setup_priority() const override; void set_counts_sensor(sensor::Sensor *counts_sensor) { this->counts_sensor_ = counts_sensor; } - void set_dielectric_constant_sensor(sensor::Sensor *dielectric_constant_sensor) { - this->dielectric_constant_sensor_ = dielectric_constant_sensor; + void set_permittivity_sensor(sensor::Sensor *permittivity_sensor) { + this->permittivity_sensor_ = permittivity_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_moisture_sensor(sensor::Sensor *moisture_sensor) { this->moisture_sensor_ = moisture_sensor; } @@ -31,7 +31,7 @@ class SMT100Component : public PollingComponent, public uart::UARTDevice { int readline_(int readch, char *buffer, int len); sensor::Sensor *counts_sensor_{nullptr}; - sensor::Sensor *dielectric_constant_sensor_{nullptr}; + sensor::Sensor *permittivity_sensor_{nullptr}; sensor::Sensor *moisture_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *voltage_sensor_{nullptr}; diff --git a/esphome/const.py b/esphome/const.py index b167935d12..4aeb5179e6 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -654,6 +654,7 @@ CONF_PAYLOAD = "payload" CONF_PAYLOAD_AVAILABLE = "payload_available" CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_PERIOD = "period" +CONF_PERMITTIVITY = "permittivity" CONF_PH = "ph" CONF_PHASE_A = "phase_a" CONF_PHASE_ANGLE = "phase_angle" diff --git a/tests/components/smt100/common.yaml b/tests/components/smt100/common.yaml index f86bd762e7..b12d7198fd 100644 --- a/tests/components/smt100/common.yaml +++ b/tests/components/smt100/common.yaml @@ -8,8 +8,8 @@ sensor: - platform: smt100 counts: name: Counts - dielectric_constant: - name: Dielectric Constant + permittivity: + name: Permittivity temperature: name: Temperature moisture: From e58baab563ed9a93dd3f55edfaac2b6b7be4af03 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:06:59 -0400 Subject: [PATCH 6/8] [ethernet] P4 changes and 5.3.0 deprecated warnings (#8457) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 55 ++++++++++++------- .../ethernet/ethernet_component.cpp | 41 +++++++++----- .../components/ethernet/ethernet_component.h | 5 +- tests/components/ethernet/common-dp83848.yaml | 4 +- tests/components/ethernet/common-ip101.yaml | 4 +- tests/components/ethernet/common-jl1101.yaml | 4 +- tests/components/ethernet/common-ksz8081.yaml | 4 +- .../ethernet/common-ksz8081rna.yaml | 4 +- tests/components/ethernet/common-lan8720.yaml | 4 +- tests/components/ethernet/common-rtl8201.yaml | 4 +- tests/components/ethernet_info/common.yaml | 4 +- 11 files changed, 88 insertions(+), 45 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8eec9510cc..ac07d02e37 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -23,8 +23,10 @@ from esphome.const import ( CONF_INTERRUPT_PIN, CONF_MANUAL_IP, CONF_MISO_PIN, + CONF_MODE, CONF_MOSI_PIN, CONF_PAGE_ID, + CONF_PIN, CONF_POLLING_INTERVAL, CONF_RESET_PIN, CONF_SPI, @@ -49,6 +51,7 @@ PHYRegister = ethernet_ns.struct("PHYRegister") CONF_PHY_ADDR = "phy_addr" CONF_MDC_PIN = "mdc_pin" CONF_MDIO_PIN = "mdio_pin" +CONF_CLK = "clk" CONF_CLK_MODE = "clk_mode" CONF_POWER_PIN = "power_pin" CONF_PHY_REGISTERS = "phy_registers" @@ -73,26 +76,18 @@ SPI_ETHERNET_TYPES = ["W5500", "DM9051"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") -emac_rmii_clock_gpio_t = cg.global_ns.enum("emac_rmii_clock_gpio_t") + CLK_MODES = { - "GPIO0_IN": ( - emac_rmii_clock_mode_t.EMAC_CLK_EXT_IN, - emac_rmii_clock_gpio_t.EMAC_CLK_IN_GPIO, - ), - "GPIO0_OUT": ( - emac_rmii_clock_mode_t.EMAC_CLK_OUT, - emac_rmii_clock_gpio_t.EMAC_APPL_CLK_OUT_GPIO, - ), - "GPIO16_OUT": ( - emac_rmii_clock_mode_t.EMAC_CLK_OUT, - emac_rmii_clock_gpio_t.EMAC_CLK_OUT_GPIO, - ), - "GPIO17_OUT": ( - emac_rmii_clock_mode_t.EMAC_CLK_OUT, - emac_rmii_clock_gpio_t.EMAC_CLK_OUT_180_GPIO, - ), + "CLK_EXT_IN": emac_rmii_clock_mode_t.EMAC_CLK_EXT_IN, + "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, } +CLK_MODES_DEPRECATED = { + "GPIO0_IN": ("CLK_EXT_IN", 0), + "GPIO0_OUT": ("CLK_OUT", 0), + "GPIO16_OUT": ("CLK_OUT", 16), + "GPIO17_OUT": ("CLK_OUT", 17), +} MANUAL_IP_SCHEMA = cv.Schema( { @@ -154,6 +149,18 @@ def _validate(config): f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." ) + elif config[CONF_TYPE] != "OPENETH": + if CONF_CLK_MODE in config: + LOGGER.warning( + "[ethernet] The 'clk_mode' option is deprecated and will be removed in ESPHome 2026.1. " + "Please update your configuration to use 'clk' instead." + ) + mode = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] + config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode[0], CONF_PIN: mode[1]}) + del config[CONF_CLK_MODE] + elif CONF_CLK not in config: + raise cv.Invalid("'clk' is a required option for [ethernet].") + return config @@ -177,14 +184,21 @@ PHY_REGISTER_SCHEMA = cv.Schema( cv.Optional(CONF_PAGE_ID): cv.hex_int, } ) +CLK_SCHEMA = cv.Schema( + { + cv.Required(CONF_MODE): cv.enum(CLK_MODES, upper=True, space="_"), + cv.Required(CONF_PIN): pins.internal_gpio_pin_number, + } +) RMII_SCHEMA = BASE_SCHEMA.extend( cv.Schema( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE, default="GPIO0_IN"): cv.enum( - CLK_MODES, upper=True, space="_" + cv.Optional(CONF_CLK_MODE): cv.enum( + CLK_MODES_DEPRECATED, upper=True, space="_" ), + cv.Optional(CONF_CLK): CLK_SCHEMA, cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), @@ -308,7 +322,8 @@ async def to_code(config): cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) - cg.add(var.set_clk_mode(*CLK_MODES[config[CONF_CLK_MODE]])) + cg.add(var.set_clk_mode(config[CONF_CLK][CONF_MODE])) + cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) for register_value in config.get(CONF_PHY_REGISTERS, []): diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 8739269f4a..19a11c6945 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -17,6 +17,22 @@ namespace esphome { namespace ethernet { +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) +// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 +#ifdef USE_ESP32_VARIANT_ESP32P4 +#undef ETH_ESP32_EMAC_DEFAULT_CONFIG +#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ + { \ + .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ + .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ + .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ + .emac_dataif_gpio = \ + {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ + .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ + } +#endif +#endif + static const char *const TAG = "ethernet"; EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -150,22 +166,18 @@ void EthernetComponent::setup() { phy_config.phy_addr = this->phy_addr_; phy_config.reset_gpio_num = this->power_pin_; -#if ESP_IDF_VERSION_MAJOR >= 5 eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) + esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; + esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; +#else esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; +#endif esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = this->clk_gpio_; + esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); -#else - mac_config.smi_mdc_gpio_num = this->mdc_pin_; - mac_config.smi_mdio_gpio_num = this->mdio_pin_; - mac_config.clock_config.rmii.clock_mode = this->clk_mode_; - mac_config.clock_config.rmii.clock_gpio = this->clk_gpio_; - - esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&mac_config); -#endif #endif switch (this->type_) { @@ -387,10 +399,11 @@ void EthernetComponent::dump_config() { ESP_LOGCONFIG(TAG, " Power Pin: %u", this->power_pin_); } ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" " MDC Pin: %u\n" " MDIO Pin: %u\n" " PHY addr: %u", - this->mdc_pin_, this->mdio_pin_, this->phy_addr_); + this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); } @@ -611,10 +624,8 @@ void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_a void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode, emac_rmii_clock_gpio_t clk_gpio) { - this->clk_mode_ = clk_mode; - this->clk_gpio_ = clk_gpio; -} +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 6cdc113aa8..1b347946f5 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -76,7 +76,8 @@ class EthernetComponent : public Component { void set_power_pin(int power_pin); void set_mdc_pin(uint8_t mdc_pin); void set_mdio_pin(uint8_t mdio_pin); - void set_clk_mode(emac_rmii_clock_mode_t clk_mode, emac_rmii_clock_gpio_t clk_gpio); + void set_clk_pin(uint8_t clk_pin); + void set_clk_mode(emac_rmii_clock_mode_t clk_mode); void add_phy_register(PHYRegister register_value); #endif void set_type(EthernetType type); @@ -123,10 +124,10 @@ class EthernetComponent : public Component { // Group all 32-bit members first int power_pin_{-1}; emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; - emac_rmii_clock_gpio_t clk_gpio_{EMAC_CLK_IN_GPIO}; std::vector phy_registers_{}; // Group all 8-bit members together + uint8_t clk_pin_{0}; uint8_t phy_addr_{0}; uint8_t mdc_pin_{23}; uint8_t mdio_pin_{18}; diff --git a/tests/components/ethernet/common-dp83848.yaml b/tests/components/ethernet/common-dp83848.yaml index 5b6ed3e8d0..140c7d0d1b 100644 --- a/tests/components/ethernet/common-dp83848.yaml +++ b/tests/components/ethernet/common-dp83848.yaml @@ -2,7 +2,9 @@ ethernet: type: DP83848 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-ip101.yaml b/tests/components/ethernet/common-ip101.yaml index 5ca369cce1..b5589220de 100644 --- a/tests/components/ethernet/common-ip101.yaml +++ b/tests/components/ethernet/common-ip101.yaml @@ -2,7 +2,9 @@ ethernet: type: IP101 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-jl1101.yaml b/tests/components/ethernet/common-jl1101.yaml index 639542d807..2ada9495a0 100644 --- a/tests/components/ethernet/common-jl1101.yaml +++ b/tests/components/ethernet/common-jl1101.yaml @@ -2,7 +2,9 @@ ethernet: type: JL1101 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-ksz8081.yaml b/tests/components/ethernet/common-ksz8081.yaml index 167606a1eb..7da8adb09a 100644 --- a/tests/components/ethernet/common-ksz8081.yaml +++ b/tests/components/ethernet/common-ksz8081.yaml @@ -2,7 +2,9 @@ ethernet: type: KSZ8081 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-ksz8081rna.yaml b/tests/components/ethernet/common-ksz8081rna.yaml index f506906b1b..df04f06132 100644 --- a/tests/components/ethernet/common-ksz8081rna.yaml +++ b/tests/components/ethernet/common-ksz8081rna.yaml @@ -2,7 +2,9 @@ ethernet: type: KSZ8081RNA mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-lan8720.yaml b/tests/components/ethernet/common-lan8720.yaml index b9ed9cb036..f227752f42 100644 --- a/tests/components/ethernet/common-lan8720.yaml +++ b/tests/components/ethernet/common-lan8720.yaml @@ -2,7 +2,9 @@ ethernet: type: LAN8720 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet/common-rtl8201.yaml b/tests/components/ethernet/common-rtl8201.yaml index 43842e7c9f..7c9c9d913c 100644 --- a/tests/components/ethernet/common-rtl8201.yaml +++ b/tests/components/ethernet/common-rtl8201.yaml @@ -2,7 +2,9 @@ ethernet: type: RTL8201 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: diff --git a/tests/components/ethernet_info/common.yaml b/tests/components/ethernet_info/common.yaml index d9a6f515b1..f45f345316 100644 --- a/tests/components/ethernet_info/common.yaml +++ b/tests/components/ethernet_info/common.yaml @@ -2,7 +2,9 @@ ethernet: type: LAN8720 mdc_pin: 23 mdio_pin: 25 - clk_mode: GPIO0_IN + clk: + pin: 0 + mode: CLK_EXT_IN phy_addr: 0 power_pin: 26 manual_ip: From db7a420e54059217458be57ce3be40a220fd204b Mon Sep 17 00:00:00 2001 From: Mathieu Rene Date: Mon, 30 Jun 2025 18:07:30 -0400 Subject: [PATCH 7/8] Fix - Pass thread TLVs down to openthread if they are defined (#9182) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/openthread/__init__.py | 72 +++++++++---------- .../components/openthread/openthread_esp.cpp | 32 +++++++-- esphome/components/openthread/tlv.py | 65 ----------------- 3 files changed, 62 insertions(+), 107 deletions(-) delete mode 100644 esphome/components/openthread/tlv.py diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 393c47e720..65138e28c7 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -22,7 +22,6 @@ from .const import ( CONF_SRP_ID, CONF_TLV, ) -from .tlv import parse_tlv CODEOWNERS = ["@mrene"] @@ -43,29 +42,40 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", config[CONF_PAN_ID]) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", config[CONF_CHANNEL]) - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{config[CONF_NETWORK_KEY]:X}".lower() - ) - if network_name := config.get(CONF_NETWORK_NAME): - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) + else: + if pan_id := config.get(CONF_PAN_ID): + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) - if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() - ) - if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() - ) - if (pskc := config.get(CONF_PSKC)) is not None: - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower()) + if channel := config.get(CONF_CHANNEL): + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", channel) - if CONF_FORCE_DATASET in config: - if config[CONF_FORCE_DATASET]: - cg.add_define("CONFIG_OPENTHREAD_FORCE_DATASET") + if network_key := config.get(CONF_NETWORK_KEY): + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:X}".lower() + ) + + if network_name := config.get(CONF_NETWORK_NAME): + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) + + if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() + ) + if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() + ) + if (pskc := config.get(CONF_PSKC)) is not None: + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() + ) + + if force_dataset := config.get(CONF_FORCE_DATASET): + if force_dataset: + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) @@ -79,22 +89,11 @@ openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) - -def _convert_tlv(config): - if tlv := config.get(CONF_TLV): - config = config.copy() - parsed_tlv = parse_tlv(tlv) - validated = _CONNECTION_SCHEMA(parsed_tlv) - config.update(validated) - del config[CONF_TLV] - return config - - _CONNECTION_SCHEMA = cv.Schema( { - cv.Inclusive(CONF_PAN_ID, "manual"): cv.hex_int, - cv.Inclusive(CONF_CHANNEL, "manual"): cv.int_, - cv.Inclusive(CONF_NETWORK_KEY, "manual"): cv.hex_int, + cv.Optional(CONF_PAN_ID): cv.hex_int, + cv.Optional(CONF_CHANNEL): cv.int_, + cv.Optional(CONF_NETWORK_KEY): cv.hex_int, cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, cv.Optional(CONF_PSKC): cv.hex_int, @@ -112,8 +111,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_TLV): cv.string_strict, } ).extend(_CONNECTION_SCHEMA), - cv.has_exactly_one_key(CONF_PAN_ID, CONF_TLV), - _convert_tlv, + cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), cv.only_with_esp_idf, only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]), ) diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index c5c817382f..dc303cef17 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -111,14 +111,36 @@ void OpenThreadComponent::ot_main() { esp_openthread_cli_create_task(); #endif ESP_LOGI(TAG, "Activating dataset..."); - otOperationalDatasetTlvs dataset; + otOperationalDatasetTlvs dataset = {}; -#ifdef CONFIG_OPENTHREAD_FORCE_DATASET - ESP_ERROR_CHECK(esp_openthread_auto_start(NULL)); -#else +#ifndef USE_OPENTHREAD_FORCE_DATASET + // Check if openthread has a valid dataset from a previous execution otError error = otDatasetGetActiveTlvs(esp_openthread_get_instance(), &dataset); - ESP_ERROR_CHECK(esp_openthread_auto_start((error == OT_ERROR_NONE) ? &dataset : NULL)); + if (error != OT_ERROR_NONE) { + // Make sure the length is 0 so we fallback to the configuration + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found OpenThread-managed dataset, ignoring esphome configuration"); + ESP_LOGI(TAG, "(set force_dataset: true to override)"); + } #endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start + size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGW(TAG, "TLV buffer too small, truncating"); + len = sizeof(dataset.mTlvs); + } + parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); + dataset.mLength = len; + } +#endif + + // Pass the existing dataset, or NULL which will use the preprocessor definitions + ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + esp_openthread_launch_mainloop(); // Clean up diff --git a/esphome/components/openthread/tlv.py b/esphome/components/openthread/tlv.py deleted file mode 100644 index 4a7d21c47d..0000000000 --- a/esphome/components/openthread/tlv.py +++ /dev/null @@ -1,65 +0,0 @@ -# Sourced from https://gist.github.com/agners/0338576e0003318b63ec1ea75adc90f9 -import binascii -import ipaddress - -from esphome.const import CONF_CHANNEL - -from . import ( - CONF_EXT_PAN_ID, - CONF_MESH_LOCAL_PREFIX, - CONF_NETWORK_KEY, - CONF_NETWORK_NAME, - CONF_PAN_ID, - CONF_PSKC, -) - -TLV_TYPES = { - 0: CONF_CHANNEL, - 1: CONF_PAN_ID, - 2: CONF_EXT_PAN_ID, - 3: CONF_NETWORK_NAME, - 4: CONF_PSKC, - 5: CONF_NETWORK_KEY, - 7: CONF_MESH_LOCAL_PREFIX, -} - - -def parse_tlv(tlv) -> dict: - data = binascii.a2b_hex(tlv) - output = {} - pos = 0 - while pos < len(data): - tag = data[pos] - pos += 1 - _len = data[pos] - pos += 1 - val = data[pos : pos + _len] - pos += _len - if tag in TLV_TYPES: - if tag == 3: - output[TLV_TYPES[tag]] = val.decode("utf-8") - elif tag == 7: - mesh_local_prefix = binascii.hexlify(val).decode("utf-8") - mesh_local_prefix_str = f"{mesh_local_prefix}0000000000000000" - ipv6_bytes = bytes.fromhex(mesh_local_prefix_str) - ipv6_address = ipaddress.IPv6Address(ipv6_bytes) - output[TLV_TYPES[tag]] = f"{ipv6_address}/64" - else: - output[TLV_TYPES[tag]] = int.from_bytes(val) - return output - - -def main(): - import sys - - args = sys.argv[1:] - parsed = parse_tlv(args[0]) - # print the parsed TLV data - for key, value in parsed.items(): - if isinstance(value, bytes): - value = value.hex() - print(f"{key}: {value}") - - -if __name__ == "__main__": - main() From b7d0f5e36b6744ad6ba0b6f74c9b7859a403f484 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Jun 2025 17:38:04 -0500 Subject: [PATCH 8/8] Fix entity hash collisions by enforcing unique names across devices per platform --- esphome/core/entity_helpers.py | 15 +-- ...ies_not_allowed_on_different_devices.yaml} | 52 ++++---- tests/integration/test_duplicate_entities.py | 122 +++++++++--------- tests/unit_tests/core/test_entity_helpers.py | 29 +++-- 4 files changed, 109 insertions(+), 109 deletions(-) rename tests/integration/fixtures/{duplicate_entities_on_different_devices.yaml => duplicate_entities_not_allowed_on_different_devices.yaml} (70%) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c95acebbf9..62dc1d7b57 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -184,25 +184,18 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # No name to validate return config - # Get the entity name and device info + # Get the entity name entity_name = config[CONF_NAME] - device_id = "" # Empty string for main device - - if CONF_DEVICE_ID in config: - device_id_obj = config[CONF_DEVICE_ID] - # Use the device ID string directly for uniqueness - device_id = device_id_obj.id # For duplicate detection, just use the sanitized name name_key = sanitize(snake_case(entity_name)) # Check for duplicates - unique_key = (device_id, platform, name_key) + unique_key = (platform, name_key) if unique_key in CORE.unique_ids: - device_prefix = f" on device '{device_id}'" if device_id else "" raise cv.Invalid( - f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " - f"Each entity on a device must have a unique name within its platform." + f"Duplicate {platform} entity with name '{entity_name}' found. " + f"Each entity must have a unique name within its platform across all devices." ) # Add to tracking set diff --git a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml similarity index 70% rename from tests/integration/fixtures/duplicate_entities_on_different_devices.yaml rename to tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml index ecc502ad28..275f36a7b9 100644 --- a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml +++ b/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml @@ -1,6 +1,6 @@ esphome: name: duplicate-entities-test - # Define devices to test multi-device duplicate handling + # Define devices to test multi-device unique name validation devices: - id: controller_1 name: Controller 1 @@ -13,31 +13,31 @@ host: api: # Port will be automatically injected logger: -# Test that duplicate entity names are allowed on different devices +# Test that duplicate entity names are NOT allowed on different devices -# Scenario 1: Same sensor name on different devices (allowed) +# Scenario 1: Different sensor names on different devices (allowed) sensor: - platform: template - name: Temperature + name: Temperature Controller 1 device_id: controller_1 lambda: return 21.0; update_interval: 0.1s - platform: template - name: Temperature + name: Temperature Controller 2 device_id: controller_2 lambda: return 22.0; update_interval: 0.1s - platform: template - name: Temperature + name: Temperature Controller 3 device_id: controller_3 lambda: return 23.0; update_interval: 0.1s # Main device sensor (no device_id) - platform: template - name: Temperature + name: Temperature Main lambda: return 20.0; update_interval: 0.1s @@ -47,20 +47,20 @@ sensor: lambda: return 60.0; update_interval: 0.1s -# Scenario 2: Same binary sensor name on different devices (allowed) +# Scenario 2: Different binary sensor names on different devices binary_sensor: - platform: template - name: Status + name: Status Controller 1 device_id: controller_1 lambda: return true; - platform: template - name: Status + name: Status Controller 2 device_id: controller_2 lambda: return false; - platform: template - name: Status + name: Status Main lambda: return true; # Main device # Different platform can have same name as sensor @@ -68,43 +68,43 @@ binary_sensor: name: Temperature lambda: return true; -# Scenario 3: Same text sensor name on different devices +# Scenario 3: Different text sensor names on different devices text_sensor: - platform: template - name: Device Info + name: Device Info Controller 1 device_id: controller_1 lambda: return {"Controller 1 Active"}; update_interval: 0.1s - platform: template - name: Device Info + name: Device Info Controller 2 device_id: controller_2 lambda: return {"Controller 2 Active"}; update_interval: 0.1s - platform: template - name: Device Info + name: Device Info Main lambda: return {"Main Device Active"}; update_interval: 0.1s -# Scenario 4: Same switch name on different devices +# Scenario 4: Different switch names on different devices switch: - platform: template - name: Power + name: Power Controller 1 device_id: controller_1 lambda: return false; turn_on_action: [] turn_off_action: [] - platform: template - name: Power + name: Power Controller 2 device_id: controller_2 lambda: return true; turn_on_action: [] turn_off_action: [] - platform: template - name: Power + name: Power Controller 3 device_id: controller_3 lambda: return false; turn_on_action: [] @@ -117,26 +117,26 @@ switch: turn_on_action: [] turn_off_action: [] -# Scenario 5: Empty names on different devices (should use device name) +# Scenario 5: Buttons with unique names button: - platform: template - name: "" + name: "Reset Controller 1" device_id: controller_1 on_press: [] - platform: template - name: "" + name: "Reset Controller 2" device_id: controller_2 on_press: [] - platform: template - name: "" + name: "Reset Main" on_press: [] # Main device -# Scenario 6: Special characters in names +# Scenario 6: Special characters in names - now with unique names number: - platform: template - name: "Temperature Setpoint!" + name: "Temperature Setpoint! Controller 1" device_id: controller_1 min_value: 10.0 max_value: 30.0 @@ -145,7 +145,7 @@ number: set_action: [] - platform: template - name: "Temperature Setpoint!" + name: "Temperature Setpoint! Controller 2" device_id: controller_2 min_value: 10.0 max_value: 30.0 diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 99968204d4..88747facb1 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities_on_different_devices( +async def test_duplicate_entities_not_allowed_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that duplicate entity names are allowed on different devices.""" + """Test that duplicate entity names are NOT allowed on different devices.""" async with run_compiled(yaml_config), api_client_connected() as client: # Get device info device_info = await client.device_info() @@ -53,41 +53,44 @@ async def test_duplicate_entities_on_different_devices( buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] - # Scenario 1: Check sensors with same "Temperature" name on different devices - temp_sensors = [s for s in sensors if s.name == "Temperature"] + # Scenario 1: Check that temperature sensors have unique names per device + temp_sensors = [s for s in sensors if "Temperature" in s.name] assert len(temp_sensors) == 4, ( f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Verify each sensor is on a different device - temp_device_ids = set() + # Verify each sensor has a unique name + temp_names = set() temp_object_ids = set() for sensor in temp_sensors: - temp_device_ids.add(sensor.device_id) + temp_names.add(sensor.name) temp_object_ids.add(sensor.object_id) - # All should have object_id "temperature" (no suffix) - assert sensor.object_id == "temperature", ( - f"Expected object_id 'temperature', got '{sensor.object_id}'" - ) - - # Should have 4 different device IDs (including None for main device) - assert len(temp_device_ids) == 4, ( - f"Temperature sensors should be on different devices, got {temp_device_ids}" + # Should have 4 unique names + assert len(temp_names) == 4, ( + f"Temperature sensors should have unique names, got {temp_names}" ) - # Scenario 2: Check binary sensors "Status" on different devices - status_binary = [b for b in binary_sensors if b.name == "Status"] + # Object IDs should also be unique + assert len(temp_object_ids) == 4, ( + f"Temperature sensors should have unique object_ids, got {temp_object_ids}" + ) + + # Scenario 2: Check binary sensors have unique names + status_binary = [b for b in binary_sensors if "Status" in b.name] assert len(status_binary) == 3, ( f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # All should have object_id "status" + # All should have unique object_ids + status_names = set() for binary in status_binary: - assert binary.object_id == "status", ( - f"Expected object_id 'status', got '{binary.object_id}'" - ) + status_names.add(binary.name) + + assert len(status_names) == 3, ( + f"Status binary sensors should have unique names, got {status_names}" + ) # Scenario 3: Check that sensor and binary_sensor can have same name temp_binary = [b for b in binary_sensors if b.name == "Temperature"] @@ -96,62 +99,65 @@ async def test_duplicate_entities_on_different_devices( ) assert temp_binary[0].object_id == "temperature" - # Scenario 4: Check text sensors "Device Info" on different devices - info_text = [t for t in text_sensors if t.name == "Device Info"] + # Scenario 4: Check text sensors have unique names + info_text = [t for t in text_sensors if "Device Info" in t.name] assert len(info_text) == 3, ( f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # All should have object_id "device_info" + # All should have unique names and object_ids + info_names = set() for text in info_text: - assert text.object_id == "device_info", ( - f"Expected object_id 'device_info', got '{text.object_id}'" - ) + info_names.add(text.name) - # Scenario 5: Check switches "Power" on different devices - power_switches = [s for s in switches if s.name == "Power"] - assert len(power_switches) == 3, ( - f"Expected exactly 3 power switches, got {len(power_switches)}" + assert len(info_names) == 3, ( + f"Device info text sensors should have unique names, got {info_names}" ) - # All should have object_id "power" + # Scenario 5: Check switches have unique names + power_switches = [s for s in switches if "Power" in s.name] + assert len(power_switches) == 4, ( + f"Expected exactly 4 power switches, got {len(power_switches)}" + ) + + # All should have unique names + power_names = set() for switch in power_switches: - assert switch.object_id == "power", ( - f"Expected object_id 'power', got '{switch.object_id}'" - ) + power_names.add(switch.name) - # Scenario 6: Check empty name buttons (should use device name) - empty_buttons = [b for b in buttons if b.name == ""] - assert len(empty_buttons) == 3, ( - f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" + assert len(power_names) == 4, ( + f"Power switches should have unique names, got {power_names}" ) - # Group by device - c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] - c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] - - # For main device, device_id is 0 - main_buttons = [b for b in empty_buttons if b.device_id == 0] - - # Check object IDs for empty name entities - assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" - assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" - assert ( - len(main_buttons) == 1 - and main_buttons[0].object_id == "duplicate-entities-test" + # Scenario 6: Check reset buttons have unique names + reset_buttons = [b for b in buttons if "Reset" in b.name] + assert len(reset_buttons) == 3, ( + f"Expected exactly 3 reset buttons, got {len(reset_buttons)}" ) - # Scenario 7: Check special characters in number names - temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] + # All should have unique names + reset_names = set() + for button in reset_buttons: + reset_names.add(button.name) + + assert len(reset_names) == 3, ( + f"Reset buttons should have unique names, got {reset_names}" + ) + + # Scenario 7: Check special characters in number names - now unique + temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name] assert len(temp_numbers) == 2, ( f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - # Special characters should be sanitized to _ in object_id + # Should have unique names + setpoint_names = set() for number in temp_numbers: - assert number.object_id == "temperature_setpoint_", ( - f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" - ) + setpoint_names.add(number.name) + + assert len(setpoint_names) == 2, ( + f"Temperature setpoint numbers should have unique names, got {setpoint_names}" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e166eeedee..a5e44c9b2a 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -505,13 +505,13 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("", "sensor", "temperature") in CORE.unique_ids + assert ("sensor", "temperature") in CORE.unique_ids # Second entity with different name should pass config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - assert ("", "sensor", "humidity") in CORE.unique_ids + assert ("sensor", "humidity") in CORE.unique_ids # Duplicate entity should fail config3 = {CONF_NAME: "Temperature"} @@ -535,24 +535,25 @@ def test_entity_duplicate_validator_with_devices() -> None: device1 = ID("device1", type="Device") device2 = ID("device2", type="Device") - # Same name on different devices should pass + # First entity on device1 should pass config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", "temperature") in CORE.unique_ids + assert ("sensor", "temperature") in CORE.unique_ids + # Same name on different device should now fail config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} - validated2 = validator(config2) - assert validated2 == config2 - assert ("device2", "sensor", "temperature") in CORE.unique_ids - - # Duplicate on same device should fail - config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} with pytest.raises( Invalid, - match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", + match=r"Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices.", ): - validator(config3) + validator(config2) + + # Different name on device2 should pass + config3 = {CONF_NAME: "Humidity", CONF_DEVICE_ID: device2} + validated3 = validator(config3) + assert validated3 == config3 + assert ("sensor", "humidity") in CORE.unique_ids def test_duplicate_entity_yaml_validation( @@ -576,10 +577,10 @@ def test_duplicate_entity_with_devices_yaml_validation( ) assert result is None - # Check for the duplicate entity error message with device + # Check for the duplicate entity error message captured = capsys.readouterr() assert ( - "Duplicate sensor entity with name 'Temperature' found on device 'device1'" + "Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices." in captured.out )