From ab14c0cd723ccd9979cf30fed7fa2d12eedf1328 Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 23 Oct 2025 17:32:02 +0200 Subject: [PATCH 01/10] [pipsolar] improve sensor readout in HA, set unknown state on timeout / error (#10292) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/pipsolar/pipsolar.cpp | 97 +++++++++---------- esphome/components/pipsolar/pipsolar.h | 3 + .../components/pipsolar/sensor/__init__.py | 96 +++++++++++++----- 3 files changed, 120 insertions(+), 76 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index b92cc3be9f..bafd5273da 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -38,7 +38,6 @@ void Pipsolar::loop() { } if (this->state_ == STATE_COMMAND_COMPLETE) { if (this->check_incoming_length_(4)) { - ESP_LOGD(TAG, "response length for command OK"); if (this->check_incoming_crc_()) { // crc ok if (this->read_buffer_[1] == 'A' && this->read_buffer_[2] == 'C' && this->read_buffer_[3] == 'K') { @@ -49,15 +48,15 @@ void Pipsolar::loop() { this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; - } else { // crc failed + // no log message necessary, check_incoming_crc_() logs this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; } } else { - ESP_LOGD(TAG, "response length for command %s not OK: with length %zu", + ESP_LOGD(TAG, "command %s response length not OK: with length %zu", this->command_queue_[this->command_queue_position_].c_str(), this->read_pos_); this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; @@ -66,46 +65,10 @@ void Pipsolar::loop() { } if (this->state_ == STATE_POLL_CHECKED) { - switch (this->enabled_polling_commands_[this->last_polling_command_].identifier) { - case POLLING_QPIRI: - ESP_LOGD(TAG, "Decode QPIRI"); - handle_qpiri_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QPIGS: - ESP_LOGD(TAG, "Decode QPIGS"); - handle_qpigs_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QMOD: - ESP_LOGD(TAG, "Decode QMOD"); - handle_qmod_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QFLAG: - ESP_LOGD(TAG, "Decode QFLAG"); - handle_qflag_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QPIWS: - ESP_LOGD(TAG, "Decode QPIWS"); - handle_qpiws_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QT: - ESP_LOGD(TAG, "Decode QT"); - handle_qt_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - case POLLING_QMN: - ESP_LOGD(TAG, "Decode QMN"); - handle_qmn_((const char *) this->read_buffer_); - this->state_ = STATE_IDLE; - break; - default: - this->state_ = STATE_IDLE; - break; - } + ESP_LOGD(TAG, "poll %s decode", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_response_(this->enabled_polling_commands_[this->last_polling_command_].identifier, + (const char *) this->read_buffer_); + this->state_ = STATE_IDLE; return; } @@ -113,6 +76,8 @@ void Pipsolar::loop() { if (this->check_incoming_crc_()) { if (this->read_buffer_[0] == '(' && this->read_buffer_[1] == 'N' && this->read_buffer_[2] == 'A' && this->read_buffer_[3] == 'K') { + ESP_LOGD(TAG, "poll %s NACK", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; return; } @@ -121,6 +86,9 @@ void Pipsolar::loop() { this->state_ = STATE_POLL_CHECKED; return; } else { + // crc failed + // no log message necessary, check_incoming_crc_() logs + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; } } @@ -158,21 +126,19 @@ void Pipsolar::loop() { // command timeout const char *command = this->command_queue_[this->command_queue_position_].c_str(); this->command_start_millis_ = millis(); - ESP_LOGD(TAG, "timeout command from queue: %s", command); + ESP_LOGD(TAG, "command %s timeout", command); this->command_queue_[this->command_queue_position_] = std::string(""); this->command_queue_position_ = (command_queue_position_ + 1) % COMMAND_QUEUE_LENGTH; this->state_ = STATE_IDLE; return; - } else { } } if (this->state_ == STATE_POLL) { if (millis() - this->command_start_millis_ > esphome::pipsolar::Pipsolar::COMMAND_TIMEOUT) { // command timeout - ESP_LOGD(TAG, "timeout command to poll: %s", - this->enabled_polling_commands_[this->last_polling_command_].command); + ESP_LOGD(TAG, "poll %s timeout", this->enabled_polling_commands_[this->last_polling_command_].command); + this->handle_poll_error_(this->enabled_polling_commands_[this->last_polling_command_].identifier); this->state_ = STATE_IDLE; - } else { } } } @@ -187,7 +153,6 @@ uint8_t Pipsolar::check_incoming_length_(uint8_t length) { uint8_t Pipsolar::check_incoming_crc_() { uint16_t crc16; crc16 = this->pipsolar_crc_(read_buffer_, read_pos_ - 3); - ESP_LOGD(TAG, "checking crc on incoming message"); if (((uint8_t) ((crc16) >> 8)) == read_buffer_[read_pos_ - 3] && ((uint8_t) ((crc16) &0xff)) == read_buffer_[read_pos_ - 2]) { ESP_LOGD(TAG, "CRC OK"); @@ -253,7 +218,7 @@ bool Pipsolar::send_next_poll_() { this->write(((uint8_t) ((crc16) &0xff))); // lowbyte // end Byte this->write(0x0D); - ESP_LOGD(TAG, "Sending polling command : %s with length %d", + ESP_LOGD(TAG, "Sending polling command: %s with length %d", this->enabled_polling_commands_[this->last_polling_command_].command, this->enabled_polling_commands_[this->last_polling_command_].length); return true; @@ -274,6 +239,38 @@ void Pipsolar::queue_command(const std::string &command) { ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str()); } +void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) { + switch (polling_command) { + case POLLING_QPIRI: + handle_qpiri_(message); + break; + case POLLING_QPIGS: + handle_qpigs_(message); + break; + case POLLING_QMOD: + handle_qmod_(message); + break; + case POLLING_QFLAG: + handle_qflag_(message); + break; + case POLLING_QPIWS: + handle_qpiws_(message); + break; + case POLLING_QT: + handle_qt_(message); + break; + case POLLING_QMN: + handle_qmn_(message); + break; + default: + break; + } +} +void Pipsolar::handle_poll_error_(ENUMPollingCommand polling_command) { + // handlers are designed in a way that an empty message sets all sensors to unknown + this->handle_poll_response_(polling_command, ""); +} + void Pipsolar::handle_qpiri_(const char *message) { if (this->last_qpiri_) { this->last_qpiri_->publish_state(message); diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 40056bac9d..beae67a4e0 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -204,6 +204,9 @@ class Pipsolar : public uart::UARTDevice, public PollingComponent { bool send_next_command_(); bool send_next_poll_(); + void handle_poll_response_(ENUMPollingCommand polling_command, const char *message); + void handle_poll_error_(ENUMPollingCommand polling_command); + // these handlers are designed in a way that an empty message sets all sensors to unknown void handle_qpiri_(const char *message); void handle_qpigs_(const char *message); void handle_qmod_(const char *message); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 929865b480..d08a877b55 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -4,11 +4,18 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, CONF_BUS_VOLTAGE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + ICON_BATTERY, ICON_CURRENT_AC, + ICON_FLASH, + ICON_GAUGE, + STATE_CLASS_MEASUREMENT, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -22,6 +29,10 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] +ICON_SOLAR_POWER = "mdi:solar-power" +ICON_SOLAR_PANEL = "mdi:solar-panel" +ICON_CURRENT_DC = "mdi:current-dc" + # QPIRI sensors CONF_GRID_RATING_VOLTAGE = "grid_rating_voltage" CONF_GRID_RATING_CURRENT = "grid_rating_current" @@ -75,16 +86,19 @@ TYPES = { unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_GRID_RATING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_RATING_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, @@ -98,11 +112,12 @@ TYPES = { ), CONF_AC_OUTPUT_RATING_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, ), CONF_AC_OUTPUT_RATING_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, ), CONF_BATTERY_RATING_VOLTAGE: sensor.sensor_schema( @@ -131,124 +146,151 @@ TYPES = { device_class=DEVICE_CLASS_VOLTAGE, ), CONF_BATTERY_TYPE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_CURRENT_MAX_AC_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, ), CONF_CURRENT_MAX_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, ), CONF_INPUT_VOLTAGE_RANGE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_OUTPUT_SOURCE_PRIORITY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_CHARGER_SOURCE_PRIORITY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PARALLEL_MAX_NUM: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_MACHINE_TYPE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_TOPOLOGY: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_OUTPUT_MODE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_BATTERY_REDISCHARGE_VOLTAGE: sensor.sensor_schema( accuracy_decimals=1, ), CONF_PV_OK_CONDITION_FOR_PARALLEL: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PV_POWER_BALANCE: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_GRID_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_GRID_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_FREQUENCY: sensor.sensor_schema( unit_of_measurement=UNIT_HERTZ, icon=ICON_CURRENT_AC, accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_APPARENT_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT_AMPS, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_AC_OUTPUT_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_OUTPUT_LOAD_PERCENT: sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, - accuracy_decimals=1, + icon=ICON_GAUGE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BUS_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + icon=ICON_FLASH, + accuracy_decimals=0, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + icon=ICON_BATTERY, + accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_CHARGING_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + icon=ICON_CURRENT_DC, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_CAPACITY_PERCENT: sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, - accuracy_decimals=1, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_INVERTER_HEAT_SINK_TEMPERATURE: sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, + accuracy_decimals=0, device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_PV_INPUT_CURRENT_FOR_BATTERY: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, + icon=ICON_SOLAR_PANEL, accuracy_decimals=1, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_PV_INPUT_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, + icon=ICON_SOLAR_PANEL, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE_SCC: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, + accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_DISCHARGE_CURRENT: sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=1, + icon=ICON_CURRENT_DC, + accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, ), CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -256,12 +298,14 @@ TYPES = { device_class=DEVICE_CLASS_VOLTAGE, ), CONF_EEPROM_VERSION: sensor.sensor_schema( - accuracy_decimals=1, + accuracy_decimals=0, ), CONF_PV_CHARGING_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, - accuracy_decimals=1, + icon=ICON_SOLAR_POWER, + accuracy_decimals=0, device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, ), } From 8da8095a6ab196f8dd1c69cafdb80a9a5d4050c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Oct 2025 10:11:13 -0700 Subject: [PATCH 02/10] [tests] Isolate gps component to prevent TinyGPSPlus millis() conflicts (#11499) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 78f5ca3344..38d1f8c2b7 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -77,6 +77,7 @@ ISOLATED_COMPONENTS = { "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", + "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", "openthread": "Conflicts with wifi: used by most components", From e490aec6b41e4876964cb3a50249c570942c7fdf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:25:36 -0700 Subject: [PATCH 03/10] Bump ruamel-yaml from 0.18.15 to 0.18.16 (#11482) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 351143591a..4a64bd39cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ esphome-dashboard==20251013.0 aioesphomeapi==42.3.0 zeroconf==0.148.0 puremagic==1.30 -ruamel.yaml==0.18.15 # dashboard_import +ruamel.yaml==0.18.16 # dashboard_import ruamel.yaml.clib==0.2.14 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 From fa3ec6f732c3b94d67feba51a820197752074a0b Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:32:07 +0200 Subject: [PATCH 04/10] [core] handle mixed IP and DNS addresses correctly in resolve_ip_address (#11503) Co-authored-by: J. Nick Koston --- esphome/helpers.py | 29 +++++++++++++++-------------- tests/unit_tests/test_helpers.py | 22 ++++++++++++++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index fb7b71775d..ea6abff50a 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -224,36 +224,37 @@ def resolve_ip_address( return res # Process hosts - cached_addresses: list[str] = [] + uncached_hosts: list[str] = [] - has_cache = address_cache is not None for h in hosts: if is_ip_address(h): - if has_cache: - # If we have a cache, treat IPs as cached - cached_addresses.append(h) - else: - # If no cache, pass IPs through to resolver with hostnames - uncached_hosts.append(h) + _add_ip_addresses_to_addrinfo([h], port, res) elif address_cache and (cached := address_cache.get_addresses(h)): - # Found in cache - cached_addresses.extend(cached) + _add_ip_addresses_to_addrinfo(cached, port, res) else: # Not cached, need to resolve if address_cache and address_cache.has_cache(): _LOGGER.info("Host %s not in cache, will need to resolve", h) uncached_hosts.append(h) - # Process cached addresses (includes direct IPs and cached lookups) - _add_ip_addresses_to_addrinfo(cached_addresses, port, res) - # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: + from aioesphomeapi.host_resolver import AddrInfo as AioAddrInfo + + from esphome.core import EsphomeError from esphome.resolver import AsyncResolver resolver = AsyncResolver(uncached_hosts, port) - addr_infos = resolver.resolve() + addr_infos: list[AioAddrInfo] = [] + try: + addr_infos = resolver.resolve() + except EsphomeError as err: + if not res: + # No pre-resolved addresses available, DNS resolution is fatal + raise + _LOGGER.info("%s (using %d already resolved IP addresses)", err, len(res)) + # Convert aioesphomeapi AddrInfo to our format for addr_info in addr_infos: sockaddr = addr_info.sockaddr diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 87ed901ecb..47b945e0eb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -454,9 +454,27 @@ def test_resolve_ip_address_mixed_list() -> None: # Mix of IP and hostname - should use async resolver result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.200" + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_mixed_list_fail() -> None: + """Test resolving a mix of IPs and hostnames with resolve failed.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.side_effect = EsphomeError( + "Error resolving IP address: [test.local]" + ) + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 1 - assert result[0][4][0] == "192.168.1.200" - MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + assert result[0][4][0] == "192.168.1.100" + MockResolver.assert_called_once_with(["test.local"], 6053) mock_resolver.resolve.assert_called_once() From de2f475dbd405506b583ef02e42509f6fff25a0a Mon Sep 17 00:00:00 2001 From: Peter Zich Date: Mon, 20 Oct 2025 06:13:13 -0700 Subject: [PATCH 05/10] [hdc1080] Make HDC1080_CMD_CONFIGURATION failure a warning (and log it) (#11355) Co-authored-by: J. Nick Koston --- esphome/components/hdc1080/hdc1080.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index 71b7cd7e6e..fa293f6fc5 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -16,7 +16,8 @@ void HDC1080Component::setup() { // if configuration fails - there is a problem if (this->write_register(HDC1080_CMD_CONFIGURATION, config, 2) != i2c::ERROR_OK) { - this->mark_failed(); + ESP_LOGW(TAG, "Failed to configure HDC1080"); + this->status_set_warning(); return; } } From febee437d67ee0e4af4ebac8af8a117269d0b0d5 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Wed, 22 Oct 2025 03:10:25 +0600 Subject: [PATCH 06/10] [uart] Make rx pin respect pullup and pulldown settings (#9248) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++++ esphome/components/uart/uart_component_esp_idf.cpp | 10 ++++++++++ esphome/components/uart/uart_component_libretiny.cpp | 7 +++++++ esphome/components/uart/uart_component_rp2040.cpp | 7 +++++++ 4 files changed, 31 insertions(+) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b2bf2bacf1..7a453dbb50 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -56,6 +56,13 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + // Use Arduino HardwareSerial UARTs if all used pins match the ones // preconfigured by the platform. For example if RX disabled but TX pin // is 1 we still want to use Serial. diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 7530856b1e..cffa3308eb 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -6,6 +6,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/gpio.h" +#include "driver/gpio.h" +#include "soc/gpio_num.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -104,6 +107,13 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8a7a301cfe..9c065fe5df 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -46,6 +46,13 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index ae3042fb77..c78691653d 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -52,6 +52,13 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + uint16_t config = get_config(); constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28}); From daeb8ef88cba3607426890f537d4fb0fae9441ae Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Thu, 23 Oct 2025 20:32:07 +0200 Subject: [PATCH 07/10] [core] handle mixed IP and DNS addresses correctly in resolve_ip_address (#11503) Co-authored-by: J. Nick Koston --- esphome/helpers.py | 29 +++++++++++++++-------------- tests/unit_tests/test_helpers.py | 22 ++++++++++++++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index fb7b71775d..ea6abff50a 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -224,36 +224,37 @@ def resolve_ip_address( return res # Process hosts - cached_addresses: list[str] = [] + uncached_hosts: list[str] = [] - has_cache = address_cache is not None for h in hosts: if is_ip_address(h): - if has_cache: - # If we have a cache, treat IPs as cached - cached_addresses.append(h) - else: - # If no cache, pass IPs through to resolver with hostnames - uncached_hosts.append(h) + _add_ip_addresses_to_addrinfo([h], port, res) elif address_cache and (cached := address_cache.get_addresses(h)): - # Found in cache - cached_addresses.extend(cached) + _add_ip_addresses_to_addrinfo(cached, port, res) else: # Not cached, need to resolve if address_cache and address_cache.has_cache(): _LOGGER.info("Host %s not in cache, will need to resolve", h) uncached_hosts.append(h) - # Process cached addresses (includes direct IPs and cached lookups) - _add_ip_addresses_to_addrinfo(cached_addresses, port, res) - # If we have uncached hosts (only non-IP hostnames), resolve them if uncached_hosts: + from aioesphomeapi.host_resolver import AddrInfo as AioAddrInfo + + from esphome.core import EsphomeError from esphome.resolver import AsyncResolver resolver = AsyncResolver(uncached_hosts, port) - addr_infos = resolver.resolve() + addr_infos: list[AioAddrInfo] = [] + try: + addr_infos = resolver.resolve() + except EsphomeError as err: + if not res: + # No pre-resolved addresses available, DNS resolution is fatal + raise + _LOGGER.info("%s (using %d already resolved IP addresses)", err, len(res)) + # Convert aioesphomeapi AddrInfo to our format for addr_info in addr_infos: sockaddr = addr_info.sockaddr diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 87ed901ecb..47b945e0eb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -454,9 +454,27 @@ def test_resolve_ip_address_mixed_list() -> None: # Mix of IP and hostname - should use async resolver result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 2 + assert result[0][4][0] == "192.168.1.100" + assert result[1][4][0] == "192.168.1.200" + MockResolver.assert_called_once_with(["test.local"], 6053) + mock_resolver.resolve.assert_called_once() + + +def test_resolve_ip_address_mixed_list_fail() -> None: + """Test resolving a mix of IPs and hostnames with resolve failed.""" + with patch("esphome.resolver.AsyncResolver") as MockResolver: + mock_resolver = MockResolver.return_value + mock_resolver.resolve.side_effect = EsphomeError( + "Error resolving IP address: [test.local]" + ) + + # Mix of IP and hostname - should use async resolver + result = helpers.resolve_ip_address(["192.168.1.100", "test.local"], 6053) + assert len(result) == 1 - assert result[0][4][0] == "192.168.1.200" - MockResolver.assert_called_once_with(["192.168.1.100", "test.local"], 6053) + assert result[0][4][0] == "192.168.1.100" + MockResolver.assert_called_once_with(["test.local"], 6053) mock_resolver.resolve.assert_called_once() From a32a1d11fb2024e2d7d917b3a1b4280edddb5a44 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 24 Oct 2025 07:51:38 +1300 Subject: [PATCH 08/10] Bump version to 2025.10.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6ed69336d2..f770defaf2 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.10.2 +PROJECT_NUMBER = 2025.10.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index db8903fd96..712dc85221 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.10.2" +__version__ = "2025.10.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 2440bbdceb385319821876d79f8a26d9646bdd85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Oct 2025 20:01:23 -0700 Subject: [PATCH 09/10] [core][sensor] Eliminate redundant default value setters in generated code (#11495) --- esphome/components/sensor/__init__.py | 4 +++- esphome/core/entity_helpers.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 7e91bb83c4..93283e4d47 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -878,7 +878,9 @@ async def setup_sensor_core_(var, config): cg.add(var.set_unit_of_measurement(unit_of_measurement)) if (accuracy_decimals := config.get(CONF_ACCURACY_DECIMALS)) is not None: cg.add(var.set_accuracy_decimals(accuracy_decimals)) - cg.add(var.set_force_update(config[CONF_FORCE_UPDATE])) + # Only set force_update if True (default is False) + if config[CONF_FORCE_UPDATE]: + cg.add(var.set_force_update(True)) if config.get(CONF_FILTERS): # must exist and not be empty filters = await build_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f0a04b4860..9b4786f835 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -105,7 +105,9 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: config[CONF_NAME], platform, ) - add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) + # Only set disabled_by_default if True (default is False) + if config[CONF_DISABLED_BY_DEFAULT]: + add(var.set_disabled_by_default(True)) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) if CONF_ICON in config: From 2c85ba037ec96974b077fe471f94d96e623209d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Oct 2025 20:01:48 -0700 Subject: [PATCH 10/10] [http_request] Pass collect_headers by const reference instead of by value (#11494) --- esphome/components/http_request/http_request.h | 2 +- esphome/components/http_request/http_request_arduino.cpp | 2 +- esphome/components/http_request/http_request_arduino.h | 2 +- esphome/components/http_request/http_request_host.cpp | 2 +- esphome/components/http_request/http_request_host.h | 2 +- esphome/components/http_request/http_request_idf.cpp | 2 +- esphome/components/http_request/http_request_idf.h | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index bb14cc6f51..40c85d51ed 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -169,7 +169,7 @@ class HttpRequestComponent : public Component { protected: virtual std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) = 0; + const std::set &collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; uint16_t redirect_limit_{}; diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index dfdbbd3fab..c64a7be554 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -17,7 +17,7 @@ static const char *const TAG = "http_request.arduino"; std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) { + const std::set &collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index c8208c74d8..b736bb56d1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -33,7 +33,7 @@ class HttpRequestArduino : public HttpRequestComponent { protected: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) override; + const std::set &collect_headers) override; }; } // namespace http_request diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index c20ea552b7..402affc1d1 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -20,7 +20,7 @@ static const char *const TAG = "http_request.host"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set response_headers) { + const std::set &response_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index fdd72e7ea5..886ba94938 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -20,7 +20,7 @@ class HttpRequestHost : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set response_headers) override; + const std::set &response_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } protected: diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a91c0bfc25..34a3fb87eb 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -55,7 +55,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) { + const std::set &collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 90dee0be68..e51b3aaebc 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -39,7 +39,7 @@ class HttpRequestIDF : public HttpRequestComponent { protected: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, - std::set collect_headers) override; + const std::set &collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{};