From 5bff9bc8d97de268bd341772d869b2f012aa71a3 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 24 Jul 2025 04:02:03 -0500 Subject: [PATCH 01/17] [ld2450] Use ``Deduplicator`` for sensors (#9863) --- esphome/components/ld2450/__init__.py | 1 + esphome/components/ld2450/ld2450.cpp | 229 +++++++++----------------- esphome/components/ld2450/ld2450.h | 77 +++------ 3 files changed, 102 insertions(+), 205 deletions(-) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 442fdaa125..cdbf8a17c4 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,6 +3,7 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] CODEOWNERS = ["@hareeshmu"] MULTI_CONF = True diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 09761b2937..fc1add8268 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -1,6 +1,5 @@ #include "ld2450.h" -#include -#include + #ifdef USE_NUMBER #include "esphome/components/number/number.h" #endif @@ -11,8 +10,8 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#define highbyte(val) (uint8_t)((val) >> 8) -#define lowbyte(val) (uint8_t)((val) &0xff) +#include +#include namespace esphome { namespace ld2450 { @@ -170,21 +169,16 @@ static inline int16_t hex_to_signed_int(const uint8_t *buffer, uint8_t offset) { } static inline float calculate_angle(float base, float hypotenuse) { - if (base < 0.0 || hypotenuse <= 0.0) { - return 0.0; + if (base < 0.0f || hypotenuse <= 0.0f) { + return 0.0f; } - float angle_radians = std::acos(base / hypotenuse); - float angle_degrees = angle_radians * (180.0 / M_PI); + float angle_radians = acosf(base / hypotenuse); + float angle_degrees = angle_radians * (180.0f / std::numbers::pi_v); return angle_degrees; } -static bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { - for (uint8_t i = 0; i < HEADER_FOOTER_SIZE; i++) { - if (header_footer[i] != buffer[i]) { - return false; // Mismatch in header/footer - } - } - return true; // Valid header/footer +static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { + return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; } void LD2450Component::setup() { @@ -217,41 +211,41 @@ void LD2450Component::dump_config() { #endif #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, "Sensors:"); - LOG_SENSOR(" ", "MovingTargetCount", this->moving_target_count_sensor_); - LOG_SENSOR(" ", "StillTargetCount", this->still_target_count_sensor_); - LOG_SENSOR(" ", "TargetCount", this->target_count_sensor_); - for (sensor::Sensor *s : this->move_x_sensors_) { - LOG_SENSOR(" ", "TargetX", s); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "MovingTargetCount", this->moving_target_count_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "StillTargetCount", this->still_target_count_sensor_); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetCount", this->target_count_sensor_); + for (auto &s : this->move_x_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetX", s); } - for (sensor::Sensor *s : this->move_y_sensors_) { - LOG_SENSOR(" ", "TargetY", s); + for (auto &s : this->move_y_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetY", s); } - for (sensor::Sensor *s : this->move_angle_sensors_) { - LOG_SENSOR(" ", "TargetAngle", s); + for (auto &s : this->move_angle_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetAngle", s); } - for (sensor::Sensor *s : this->move_distance_sensors_) { - LOG_SENSOR(" ", "TargetDistance", s); + for (auto &s : this->move_distance_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetDistance", s); } - for (sensor::Sensor *s : this->move_resolution_sensors_) { - LOG_SENSOR(" ", "TargetResolution", s); + for (auto &s : this->move_resolution_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetResolution", s); } - for (sensor::Sensor *s : this->move_speed_sensors_) { - LOG_SENSOR(" ", "TargetSpeed", s); + for (auto &s : this->move_speed_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetSpeed", s); } - for (sensor::Sensor *s : this->zone_target_count_sensors_) { - LOG_SENSOR(" ", "ZoneTargetCount", s); + for (auto &s : this->zone_target_count_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "ZoneTargetCount", s); } - for (sensor::Sensor *s : this->zone_moving_target_count_sensors_) { - LOG_SENSOR(" ", "ZoneMovingTargetCount", s); + for (auto &s : this->zone_moving_target_count_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "ZoneMovingTargetCount", s); } - for (sensor::Sensor *s : this->zone_still_target_count_sensors_) { - LOG_SENSOR(" ", "ZoneStillTargetCount", s); + for (auto &s : this->zone_still_target_count_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "ZoneStillTargetCount", s); } #endif #ifdef USE_TEXT_SENSOR ESP_LOGCONFIG(TAG, "Text Sensors:"); LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); - LOG_TEXT_SENSOR(" ", "Mac", this->mac_text_sensor_); + LOG_TEXT_SENSOR(" ", "MAC address", this->mac_text_sensor_); for (text_sensor::TextSensor *s : this->direction_text_sensors_) { LOG_TEXT_SENSOR(" ", "Direction", s); } @@ -419,19 +413,19 @@ void LD2450Component::send_command_(uint8_t command, const uint8_t *command_valu if (command_value != nullptr) { len += command_value_len; } - uint8_t len_cmd[] = {lowbyte(len), highbyte(len), command, 0x00}; + // 2 length bytes (low, high) + 2 command bytes (low, high) + uint8_t len_cmd[] = {len, 0x00, command, 0x00}; this->write_array(len_cmd, sizeof(len_cmd)); - // command value bytes if (command_value != nullptr) { - for (uint8_t i = 0; i < command_value_len; i++) { - this->write_byte(command_value[i]); - } + this->write_array(command_value, command_value_len); } // frame footer bytes this->write_array(CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); - // FIXME to remove - delay(50); // NOLINT + + if (command != CMD_ENABLE_CONF && command != CMD_DISABLE_CONF) { + delay(50); // NOLINT + } } // LD2450 Radar data message: @@ -459,8 +453,8 @@ void LD2450Component::handle_periodic_data_() { int16_t target_count = 0; int16_t still_target_count = 0; int16_t moving_target_count = 0; + int16_t res = 0; int16_t start = 0; - int16_t val = 0; int16_t tx = 0; int16_t ty = 0; int16_t td = 0; @@ -478,85 +472,43 @@ void LD2450Component::handle_periodic_data_() { start = TARGET_X + index * 8; is_moving = false; // tx is used for further calculations, so always needs to be populated - val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - tx = val; - sensor::Sensor *sx = this->move_x_sensors_[index]; - if (sx != nullptr) { - if (this->cached_target_data_[index].x != val) { - sx->publish_state(val); - this->cached_target_data_[index].x = val; - } - } + tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); + SAFE_PUBLISH_SENSOR(this->move_x_sensors_[index], tx); // Y start = TARGET_Y + index * 8; - // ty is used for further calculations, so always needs to be populated - val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); - ty = val; - sensor::Sensor *sy = this->move_y_sensors_[index]; - if (sy != nullptr) { - if (this->cached_target_data_[index].y != val) { - sy->publish_state(val); - this->cached_target_data_[index].y = val; - } - } + ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); + SAFE_PUBLISH_SENSOR(this->move_y_sensors_[index], ty); // RESOLUTION start = TARGET_RESOLUTION + index * 8; - sensor::Sensor *sr = this->move_resolution_sensors_[index]; - if (sr != nullptr) { - val = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; - if (this->cached_target_data_[index].resolution != val) { - sr->publish_state(val); - this->cached_target_data_[index].resolution = val; - } - } + res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; + SAFE_PUBLISH_SENSOR(this->move_resolution_sensors_[index], res); #endif // SPEED start = TARGET_SPEED + index * 8; - val = ld2450::decode_speed(this->buffer_data_[start], this->buffer_data_[start + 1]); - ts = val; - if (val) { + ts = ld2450::decode_speed(this->buffer_data_[start], this->buffer_data_[start + 1]); + if (ts) { is_moving = true; moving_target_count++; } #ifdef USE_SENSOR - sensor::Sensor *ss = this->move_speed_sensors_[index]; - if (ss != nullptr) { - if (this->cached_target_data_[index].speed != val) { - ss->publish_state(val); - this->cached_target_data_[index].speed = val; - } - } + SAFE_PUBLISH_SENSOR(this->move_speed_sensors_[index], ts); #endif // DISTANCE // Optimized: use already decoded tx and ty values, replace pow() with multiplication int32_t x_squared = (int32_t) tx * tx; int32_t y_squared = (int32_t) ty * ty; - val = (uint16_t) sqrt(x_squared + y_squared); - td = val; - if (val > 0) { + td = (uint16_t) sqrtf(x_squared + y_squared); + if (td > 0) { target_count++; } #ifdef USE_SENSOR - sensor::Sensor *sd = this->move_distance_sensors_[index]; - if (sd != nullptr) { - if (this->cached_target_data_[index].distance != val) { - sd->publish_state(val); - this->cached_target_data_[index].distance = val; - } - } + SAFE_PUBLISH_SENSOR(this->move_distance_sensors_[index], td); // ANGLE angle = ld2450::calculate_angle(static_cast(ty), static_cast(td)); if (tx > 0) { angle = angle * -1; } - sensor::Sensor *sa = this->move_angle_sensors_[index]; - if (sa != nullptr) { - if (std::isnan(this->cached_target_data_[index].angle) || - std::abs(this->cached_target_data_[index].angle - angle) > 0.1f) { - sa->publish_state(angle); - this->cached_target_data_[index].angle = angle; - } - } + SAFE_PUBLISH_SENSOR(this->move_angle_sensors_[index], angle); #endif #ifdef USE_TEXT_SENSOR // DIRECTION @@ -570,11 +522,9 @@ void LD2450Component::handle_periodic_data_() { direction = DIRECTION_STATIONARY; } text_sensor::TextSensor *tsd = this->direction_text_sensors_[index]; - if (tsd != nullptr) { - if (this->cached_target_data_[index].direction != direction) { - tsd->publish_state(find_str(ld2450::DIRECTION_BY_UINT, direction)); - this->cached_target_data_[index].direction = direction; - } + const auto *dir_str = find_str(ld2450::DIRECTION_BY_UINT, direction); + if (tsd != nullptr && (!tsd->has_state() || tsd->get_state() != dir_str)) { + tsd->publish_state(dir_str); } #endif @@ -599,53 +549,19 @@ void LD2450Component::handle_periodic_data_() { zone_all_targets = zone_still_targets + zone_moving_targets; // Publish Still Target Count in Zones - sensor::Sensor *szstc = this->zone_still_target_count_sensors_[index]; - if (szstc != nullptr) { - if (this->cached_zone_data_[index].still_count != zone_still_targets) { - szstc->publish_state(zone_still_targets); - this->cached_zone_data_[index].still_count = zone_still_targets; - } - } + SAFE_PUBLISH_SENSOR(this->zone_still_target_count_sensors_[index], zone_still_targets); // Publish Moving Target Count in Zones - sensor::Sensor *szmtc = this->zone_moving_target_count_sensors_[index]; - if (szmtc != nullptr) { - if (this->cached_zone_data_[index].moving_count != zone_moving_targets) { - szmtc->publish_state(zone_moving_targets); - this->cached_zone_data_[index].moving_count = zone_moving_targets; - } - } + SAFE_PUBLISH_SENSOR(this->zone_moving_target_count_sensors_[index], zone_moving_targets); // Publish All Target Count in Zones - sensor::Sensor *sztc = this->zone_target_count_sensors_[index]; - if (sztc != nullptr) { - if (this->cached_zone_data_[index].total_count != zone_all_targets) { - sztc->publish_state(zone_all_targets); - this->cached_zone_data_[index].total_count = zone_all_targets; - } - } - + SAFE_PUBLISH_SENSOR(this->zone_target_count_sensors_[index], zone_all_targets); } // End loop thru zones // Target Count - if (this->target_count_sensor_ != nullptr) { - if (this->cached_global_data_.target_count != target_count) { - this->target_count_sensor_->publish_state(target_count); - this->cached_global_data_.target_count = target_count; - } - } + SAFE_PUBLISH_SENSOR(this->target_count_sensor_, target_count); // Still Target Count - if (this->still_target_count_sensor_ != nullptr) { - if (this->cached_global_data_.still_count != still_target_count) { - this->still_target_count_sensor_->publish_state(still_target_count); - this->cached_global_data_.still_count = still_target_count; - } - } + SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count); // Moving Target Count - if (this->moving_target_count_sensor_ != nullptr) { - if (this->cached_global_data_.moving_count != moving_target_count) { - this->moving_target_count_sensor_->publish_state(moving_target_count); - this->cached_global_data_.moving_count = moving_target_count; - } - } + SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count); #endif #ifdef USE_BINARY_SENSOR @@ -942,28 +858,33 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); } #ifdef USE_SENSOR -void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) { this->move_x_sensors_[target] = s; } -void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) { this->move_y_sensors_[target] = s; } +// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. +void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) { + this->move_x_sensors_[target] = new SensorWithDedup(s); +} +void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) { + this->move_y_sensors_[target] = new SensorWithDedup(s); +} void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) { - this->move_speed_sensors_[target] = s; + this->move_speed_sensors_[target] = new SensorWithDedup(s); } void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) { - this->move_angle_sensors_[target] = s; + this->move_angle_sensors_[target] = new SensorWithDedup(s); } void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) { - this->move_distance_sensors_[target] = s; + this->move_distance_sensors_[target] = new SensorWithDedup(s); } void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) { - this->move_resolution_sensors_[target] = s; + this->move_resolution_sensors_[target] = new SensorWithDedup(s); } void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_target_count_sensors_[zone] = s; + this->zone_target_count_sensors_[zone] = new SensorWithDedup(s); } void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_still_target_count_sensors_[zone] = s; + this->zone_still_target_count_sensors_[zone] = new SensorWithDedup(s); } void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_moving_target_count_sensors_[zone] = s; + this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup(s); } #endif #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index ae72a0d8cb..0fba0f9be3 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -1,12 +1,7 @@ #pragma once -#include "esphome/components/uart/uart.h" -#include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/helpers.h" -#include "esphome/core/preferences.h" -#include -#include +#include "esphome/core/component.h" #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -29,18 +24,23 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -#ifndef M_PI -#define M_PI 3.14 -#endif +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include namespace esphome { namespace ld2450 { +using namespace ld24xx; + // Constants -static const uint8_t DEFAULT_PRESENCE_TIMEOUT = 5; // Timeout to reset presense status 5 sec. -static const uint8_t MAX_LINE_LENGTH = 41; // Max characters for serial buffer -static const uint8_t MAX_TARGETS = 3; // Max 3 Targets in LD2450 -static const uint8_t MAX_ZONES = 3; // Max 3 Zones in LD2450 +static constexpr uint8_t DEFAULT_PRESENCE_TIMEOUT = 5; // Timeout to reset presense status 5 sec. +static constexpr uint8_t MAX_LINE_LENGTH = 41; // Max characters for serial buffer +static constexpr uint8_t MAX_TARGETS = 3; // Max 3 Targets in LD2450 +static constexpr uint8_t MAX_ZONES = 3; // Max 3 Zones in LD2450 enum Direction : uint8_t { DIRECTION_APPROACHING = 0, @@ -81,9 +81,9 @@ class LD2450Component : public Component, public uart::UARTDevice { SUB_BINARY_SENSOR(target) #endif #ifdef USE_SENSOR - SUB_SENSOR(moving_target_count) - SUB_SENSOR(still_target_count) - SUB_SENSOR(target_count) + SUB_SENSOR_WITH_DEDUP(moving_target_count, uint8_t) + SUB_SENSOR_WITH_DEDUP(still_target_count, uint8_t) + SUB_SENSOR_WITH_DEDUP(target_count, uint8_t) #endif #ifdef USE_TEXT_SENSOR SUB_TEXT_SENSOR(mac) @@ -176,48 +176,23 @@ class LD2450Component : public Component, public uart::UARTDevice { Target target_info_[MAX_TARGETS]; Zone zone_config_[MAX_ZONES]; - // Change detection - cache previous values to avoid redundant publishes - // All values are initialized to sentinel values that are outside the valid sensor ranges - // to ensure the first real measurement is always published - struct CachedTargetData { - int16_t x = std::numeric_limits::min(); // -32768, outside range of -4860 to 4860 - int16_t y = std::numeric_limits::min(); // -32768, outside range of 0 to 7560 - int16_t speed = std::numeric_limits::min(); // -32768, outside practical sensor range - uint16_t resolution = std::numeric_limits::max(); // 65535, unlikely resolution value - uint16_t distance = std::numeric_limits::max(); // 65535, outside range of 0 to ~8990 - Direction direction = DIRECTION_UNDEFINED; // Undefined, will differ from any real direction - float angle = NAN; // NAN, safe sentinel for floats - } cached_target_data_[MAX_TARGETS]; - - struct CachedZoneData { - uint8_t still_count = std::numeric_limits::max(); // 255, unlikely zone count - uint8_t moving_count = std::numeric_limits::max(); // 255, unlikely zone count - uint8_t total_count = std::numeric_limits::max(); // 255, unlikely zone count - } cached_zone_data_[MAX_ZONES]; - - struct CachedGlobalData { - uint8_t target_count = std::numeric_limits::max(); // 255, max 3 targets possible - uint8_t still_count = std::numeric_limits::max(); // 255, max 3 targets possible - uint8_t moving_count = std::numeric_limits::max(); // 255, max 3 targets possible - } cached_global_data_; - #ifdef USE_NUMBER ESPPreferenceObject pref_; // only used when numbers are in use ZoneOfNumbers zone_numbers_[MAX_ZONES]; #endif #ifdef USE_SENSOR - std::vector move_x_sensors_ = std::vector(MAX_TARGETS); - std::vector move_y_sensors_ = std::vector(MAX_TARGETS); - std::vector move_speed_sensors_ = std::vector(MAX_TARGETS); - std::vector move_angle_sensors_ = std::vector(MAX_TARGETS); - std::vector move_distance_sensors_ = std::vector(MAX_TARGETS); - std::vector move_resolution_sensors_ = std::vector(MAX_TARGETS); - std::vector zone_target_count_sensors_ = std::vector(MAX_ZONES); - std::vector zone_still_target_count_sensors_ = std::vector(MAX_ZONES); - std::vector zone_moving_target_count_sensors_ = std::vector(MAX_ZONES); + std::array *, MAX_TARGETS> move_x_sensors_{}; + std::array *, MAX_TARGETS> move_y_sensors_{}; + std::array *, MAX_TARGETS> move_speed_sensors_{}; + std::array *, MAX_TARGETS> move_angle_sensors_{}; + std::array *, MAX_TARGETS> move_distance_sensors_{}; + std::array *, MAX_TARGETS> move_resolution_sensors_{}; + std::array *, MAX_ZONES> zone_target_count_sensors_{}; + std::array *, MAX_ZONES> zone_still_target_count_sensors_{}; + std::array *, MAX_ZONES> zone_moving_target_count_sensors_{}; #endif #ifdef USE_TEXT_SENSOR - std::vector direction_text_sensors_ = std::vector(3); + std::array direction_text_sensors_{}; #endif }; From 1344103086a3bcfffd75d27939cf2f168010cf0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jul 2025 01:04:00 -1000 Subject: [PATCH 02/17] [core] Revert #9851 and rename ESPHOME_CORES to ESPHOME_THREAD (#9862) --- esphome/components/esp32/__init__.py | 18 ++----------- esphome/components/esp8266/__init__.py | 4 +-- esphome/components/host/__init__.py | 4 +-- esphome/components/libretiny/__init__.py | 4 +-- esphome/components/nrf52/__init__.py | 4 +-- esphome/components/rp2040/__init__.py | 4 +-- esphome/const.py | 10 ++++---- esphome/core/defines.h | 4 +-- esphome/core/scheduler.cpp | 32 ++++++++++++------------ esphome/core/scheduler.h | 18 ++++++------- 10 files changed, 44 insertions(+), 58 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e24815741a..5dd2288076 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -31,7 +31,7 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_ESP32, - CoreModel, + ThreadModel, __version__, ) from esphome.core import CORE, HexInt, TimePeriod @@ -98,16 +98,6 @@ ARDUINO_ALLOWED_VARIANTS = [ VARIANT_ESP32S3, ] -# Single-core ESP32 variants -SINGLE_CORE_VARIANTS = frozenset( - [ - VARIANT_ESP32S2, - VARIANT_ESP32C3, - VARIANT_ESP32C6, - VARIANT_ESP32H2, - ] -) - def get_cpu_frequencies(*frequencies): return [str(x) + "MHZ" for x in frequencies] @@ -724,11 +714,7 @@ async def to_code(config): cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) - # Set threading model based on core count - if config[CONF_VARIANT] in SINGLE_CORE_VARIANTS: - cg.add_define(CoreModel.SINGLE) - else: - cg.add_define(CoreModel.MULTI_ATOMICS) + cg.add_define(ThreadModel.MULTI_ATOMICS) cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "strict") diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d08d7121b7..0184c25965 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -15,7 +15,7 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_ESP8266, - CoreModel, + ThreadModel, ) from esphome.core import CORE, coroutine_with_priority from esphome.helpers import copy_file_if_changed @@ -188,7 +188,7 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "ESP8266") - cg.add_define(CoreModel.SINGLE) + cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 2d77f2f7ab..ba05e497c8 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -7,7 +7,7 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_HOST, - CoreModel, + ThreadModel, ) from esphome.core import CORE @@ -44,7 +44,7 @@ async def to_code(config): cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) cg.add_build_flag("-std=gnu++20") cg.add_define("ESPHOME_BOARD", "host") - cg.add_define(CoreModel.MULTI_ATOMICS) + cg.add_define(ThreadModel.MULTI_ATOMICS) cg.add_platformio_option("platform", "platformio/native") cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "strict") diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 7f2a0bc0a5..178660cb40 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -20,7 +20,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, - CoreModel, + ThreadModel, __version__, ) from esphome.core import CORE @@ -261,7 +261,7 @@ async def component_to_code(config): cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", FAMILY_FRIENDLY[config[CONF_FAMILY]]) - cg.add_define(CoreModel.MULTI_NO_ATOMICS) + cg.add_define(ThreadModel.MULTI_NO_ATOMICS) # force using arduino framework cg.add_platformio_option("framework", "arduino") diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 870c51066c..17807b9e2b 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -23,7 +23,7 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_NRF52, - CoreModel, + ThreadModel, ) from esphome.core import CORE, EsphomeError, coroutine_with_priority from esphome.storage_json import StorageJSON @@ -110,7 +110,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "NRF52") # nRF52 processors are single-core - cg.add_define(CoreModel.SINGLE) + cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option(CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK]) cg.add_platformio_option( "platform", diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 28c3bbd70c..46eabb5325 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_RP2040, - CoreModel, + ThreadModel, ) from esphome.core import CORE, EsphomeError, coroutine_with_priority from esphome.helpers import copy_file_if_changed, mkdir_p, read_file, write_file @@ -172,7 +172,7 @@ async def to_code(config): cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", "RP2040") - cg.add_define(CoreModel.SINGLE) + cg.add_define(ThreadModel.SINGLE) cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) diff --git a/esphome/const.py b/esphome/const.py index 627b6bac18..7d373ff26c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -35,12 +35,12 @@ class Framework(StrEnum): ZEPHYR = "zephyr" -class CoreModel(StrEnum): - """Core model identifiers for ESPHome scheduler.""" +class ThreadModel(StrEnum): + """Threading model identifiers for ESPHome scheduler.""" - SINGLE = "ESPHOME_CORES_SINGLE" - MULTI_NO_ATOMICS = "ESPHOME_CORES_MULTI_NO_ATOMICS" - MULTI_ATOMICS = "ESPHOME_CORES_MULTI_ATOMICS" + SINGLE = "ESPHOME_THREAD_SINGLE" + MULTI_NO_ATOMICS = "ESPHOME_THREAD_MULTI_NO_ATOMICS" + MULTI_ATOMICS = "ESPHOME_THREAD_MULTI_ATOMICS" class PlatformFramework(Enum): diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9355d56084..348f288863 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -15,8 +15,8 @@ #define ESPHOME_VARIANT "ESP32" #define ESPHOME_DEBUG_SCHEDULER -// Default threading model for static analysis (ESP32 is multi-core with atomics) -#define ESPHOME_CORES_MULTI_ATOMICS +// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +#define ESPHOME_THREAD_MULTI_ATOMICS // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index fc5d43d262..dd80199dc0 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -84,7 +84,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type item->callback = std::move(func); item->remove = false; -#ifndef ESPHOME_CORES_SINGLE +#ifndef ESPHOME_THREAD_SINGLE // Special handling for defer() (delay = 0, type = TIMEOUT) // Single-core platforms don't need thread-safe defer handling if (delay == 0 && type == SchedulerItem::TIMEOUT) { @@ -94,7 +94,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type this->defer_queue_.push_back(std::move(item)); return; } -#endif /* not ESPHOME_CORES_SINGLE */ +#endif /* not ESPHOME_THREAD_SINGLE */ // Get fresh timestamp for new timer/interval - ensures accurate scheduling const auto now = this->millis_64_(millis()); // Fresh millis() call @@ -238,7 +238,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { return item->next_execution_ - now_64; } void HOT Scheduler::call(uint32_t now) { -#ifndef ESPHOME_CORES_SINGLE +#ifndef ESPHOME_THREAD_SINGLE // Process defer queue first to guarantee FIFO execution order for deferred items. // Previously, defer() used the heap which gave undefined order for equal timestamps, // causing race conditions on multi-core systems (ESP32, BK7200). @@ -268,7 +268,7 @@ void HOT Scheduler::call(uint32_t now) { this->execute_item_(item.get(), now); } } -#endif /* not ESPHOME_CORES_SINGLE */ +#endif /* not ESPHOME_THREAD_SINGLE */ // Convert the fresh timestamp from main loop to 64-bit for scheduler operations const auto now_64 = this->millis_64_(now); // 'now' from parameter - fresh from Application::loop() @@ -280,15 +280,15 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; std::vector> old_items; -#ifdef ESPHOME_CORES_MULTI_ATOMICS +#ifdef ESPHOME_THREAD_MULTI_ATOMICS const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, major_dbg, last_dbg); -#else /* not ESPHOME_CORES_MULTI_ATOMICS */ +#else /* not ESPHOME_THREAD_MULTI_ATOMICS */ ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%" PRIu16 ", %" PRIu32 ")", this->items_.size(), now_64, this->millis_major_, this->last_millis_); -#endif /* else ESPHOME_CORES_MULTI_ATOMICS */ +#endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ // Cleanup before debug output this->cleanup_(); while (!this->items_.empty()) { @@ -473,7 +473,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c size_t total_cancelled = 0; // Check all containers for matching items -#ifndef ESPHOME_CORES_SINGLE +#ifndef ESPHOME_THREAD_SINGLE // Only check defer queue for timeouts (intervals never go there) if (type == SchedulerItem::TIMEOUT) { for (auto &item : this->defer_queue_) { @@ -483,7 +483,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c } } } -#endif /* not ESPHOME_CORES_SINGLE */ +#endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap for (auto &item : this->items_) { @@ -509,9 +509,9 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c uint64_t Scheduler::millis_64_(uint32_t now) { // THREAD SAFETY NOTE: // This function has three implementations, based on the precompiler flags - // - ESPHOME_CORES_SINGLE - Runs on single-core platforms (ESP8266, RP2040, etc.) - // - ESPHOME_CORES_MULTI_NO_ATOMICS - Runs on multi-core platforms without atomics (LibreTiny) - // - ESPHOME_CORES_MULTI_ATOMICS - Runs on multi-core platforms with atomics (ESP32, HOST, etc.) + // - ESPHOME_THREAD_SINGLE - Runs on single-threaded platforms (ESP8266, RP2040, etc.) + // - ESPHOME_THREAD_MULTI_NO_ATOMICS - Runs on multi-threaded platforms without atomics (LibreTiny) + // - ESPHOME_THREAD_MULTI_ATOMICS - Runs on multi-threaded platforms with atomics (ESP32, HOST, etc.) // // Make sure all changes are synchronized if you edit this function. // @@ -520,7 +520,7 @@ uint64_t Scheduler::millis_64_(uint32_t now) { // helps maintain accuracy. // -#ifdef ESPHOME_CORES_SINGLE +#ifdef ESPHOME_THREAD_SINGLE // This is the single core implementation. // // Single-core platforms have no concurrency, so this is a simple implementation @@ -546,7 +546,7 @@ uint64_t Scheduler::millis_64_(uint32_t now) { // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time return now + (static_cast(major) << 32); -#elif defined(ESPHOME_CORES_MULTI_NO_ATOMICS) +#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) // This is the multi core no atomics implementation. // // Without atomics, this implementation uses locks more aggressively: @@ -595,7 +595,7 @@ uint64_t Scheduler::millis_64_(uint32_t now) { // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time return now + (static_cast(major) << 32); -#elif defined(ESPHOME_CORES_MULTI_ATOMICS) +#elif defined(ESPHOME_THREAD_MULTI_ATOMICS) // This is the multi core with atomics implementation. // // Uses atomic operations with acquire/release semantics to ensure coherent @@ -660,7 +660,7 @@ uint64_t Scheduler::millis_64_(uint32_t now) { #else #error \ - "No platform threading model defined. One of ESPHOME_CORES_SINGLE, ESPHOME_CORES_MULTI_NO_ATOMICS, or ESPHOME_CORES_MULTI_ATOMICS must be defined." + "No platform threading model defined. One of ESPHOME_THREAD_SINGLE, ESPHOME_THREAD_MULTI_NO_ATOMICS, or ESPHOME_THREAD_MULTI_ATOMICS must be defined." #endif } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c14b7debe4..7bf83f7877 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -5,7 +5,7 @@ #include #include #include -#ifdef ESPHOME_CORES_MULTI_ATOMICS +#ifdef ESPHOME_THREAD_MULTI_ATOMICS #include #endif @@ -200,13 +200,13 @@ class Scheduler { Mutex lock_; std::vector> items_; std::vector> to_add_; -#ifndef ESPHOME_CORES_SINGLE +#ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save 40 bytes of RAM std::deque> defer_queue_; // FIFO queue for defer() calls -#endif /* ESPHOME_CORES_SINGLE */ +#endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; -#ifdef ESPHOME_CORES_MULTI_ATOMICS +#ifdef ESPHOME_THREAD_MULTI_ATOMICS /* * Multi-threaded platforms with atomic support: last_millis_ needs atomic for lock-free updates * @@ -218,10 +218,10 @@ class Scheduler { * it also observes the corresponding increment of `millis_major_`. */ std::atomic last_millis_{0}; -#else /* not ESPHOME_CORES_MULTI_ATOMICS */ +#else /* not ESPHOME_THREAD_MULTI_ATOMICS */ // Platforms without atomic support or single-threaded platforms uint32_t last_millis_{0}; -#endif /* else ESPHOME_CORES_MULTI_ATOMICS */ +#endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ /* * Upper 16 bits of the 64-bit millis counter. Incremented only while holding @@ -229,11 +229,11 @@ class Scheduler { * Ordering relative to `last_millis_` is provided by its release store and the * corresponding acquire loads. */ -#ifdef ESPHOME_CORES_MULTI_ATOMICS +#ifdef ESPHOME_THREAD_MULTI_ATOMICS std::atomic millis_major_{0}; -#else /* not ESPHOME_CORES_MULTI_ATOMICS */ +#else /* not ESPHOME_THREAD_MULTI_ATOMICS */ uint16_t millis_major_{0}; -#endif /* else ESPHOME_CORES_MULTI_ATOMICS */ +#endif /* else ESPHOME_THREAD_MULTI_ATOMICS */ }; } // namespace esphome From ba1de5feff8026368b36fd8d27f368675e1dc19f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:18:29 +1200 Subject: [PATCH 03/17] [CI] Refactor auto-label workflow: modular architecture, CODEOWNERS automation, and performance improvements (#9860) --- .github/workflows/auto-label-pr.yml | 867 +++++++++++++++------------- 1 file changed, 474 insertions(+), 393 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index b30f6cf28a..17c77a1920 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -23,24 +23,6 @@ jobs: - name: Checkout uses: actions/checkout@v4.2.2 - - name: Get changes - id: changes - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get PR number - pr_number="${{ github.event.pull_request.number }}" - - # Get list of changed files using gh CLI - files=$(gh pr diff $pr_number --name-only) - echo "files<> $GITHUB_OUTPUT - echo "$files" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # Get file stats (additions + deletions) using gh CLI - stats=$(gh pr view $pr_number --json files --jq '.files | map(.additions + .deletions) | add') - echo "total_changes=${stats:-0}" >> $GITHUB_OUTPUT - - name: Generate a token id: generate-token uses: actions/create-github-app-token@v2 @@ -55,93 +37,453 @@ jobs: script: | const fs = require('fs'); + // Constants + const SMALL_PR_THRESHOLD = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); + const MAX_LABELS = parseInt('${{ env.MAX_LABELS }}'); + const TOO_BIG_THRESHOLD = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); + const BOT_COMMENT_MARKER = ''; + const CODEOWNERS_MARKER = ''; + const TOO_BIG_MARKER = ''; + + const MANAGED_LABELS = [ + 'new-component', + 'new-platform', + 'new-target-platform', + 'merging-to-release', + 'merging-to-beta', + 'core', + 'small-pr', + 'dashboard', + 'github-actions', + 'by-code-owner', + 'has-tests', + 'needs-tests', + 'needs-docs', + 'needs-codeowners', + 'too-big', + 'labeller-recheck' + ]; + + const DOCS_PR_PATTERNS = [ + /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, + /esphome\/esphome-docs#\d+/ + ]; + + // Global state const { owner, repo } = context.repo; const pr_number = context.issue.number; - // Hidden marker to identify bot comments from this workflow - const BOT_COMMENT_MARKER = ''; - - // Get current labels + // Get current labels and PR data const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: pr_number }); const currentLabels = currentLabelsData.map(label => label.name); - - // Define managed labels that this workflow controls const managedLabels = currentLabels.filter(label => - label.startsWith('component: ') || - [ - 'new-component', - 'new-platform', - 'new-target-platform', - 'merging-to-release', - 'merging-to-beta', - 'core', - 'small-pr', - 'dashboard', - 'github-actions', - 'by-code-owner', - 'has-tests', - 'needs-tests', - 'needs-docs', - 'too-big', - 'labeller-recheck' - ].includes(label) + label.startsWith('component: ') || MANAGED_LABELS.includes(label) ); + const { data: prFiles } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: pr_number + }); + + // Calculate data from PR files + const changedFiles = prFiles.map(file => file.filename); + const totalChanges = prFiles.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); + console.log('Current labels:', currentLabels.join(', ')); - console.log('Managed labels:', managedLabels.join(', ')); - - // Get changed files - const changedFiles = `${{ steps.changes.outputs.files }}`.split('\n').filter(f => f.length > 0); - const totalChanges = parseInt('${{ steps.changes.outputs.total_changes }}') || 0; - console.log('Changed files:', changedFiles.length); console.log('Total changes:', totalChanges); - const labels = new Set(); - - // Fetch TARGET_PLATFORMS and PLATFORM_COMPONENTS from API - let targetPlatforms = []; - let platformComponents = []; - - try { - const response = await fetch('https://data.esphome.io/components.json'); - const componentsData = await response.json(); - - // Extract target platforms and platform components directly from API - targetPlatforms = componentsData.target_platforms || []; - platformComponents = componentsData.platform_components || []; - - console.log('Target platforms from API:', targetPlatforms.length, targetPlatforms); - console.log('Platform components from API:', platformComponents.length, platformComponents); - } catch (error) { - console.log('Failed to fetch components data from API:', error.message); + // Fetch API data + async function fetchApiData() { + try { + const response = await fetch('https://data.esphome.io/components.json'); + const componentsData = await response.json(); + return { + targetPlatforms: componentsData.target_platforms || [], + platformComponents: componentsData.platform_components || [] + }; + } catch (error) { + console.log('Failed to fetch components data from API:', error.message); + return { targetPlatforms: [], platformComponents: [] }; + } } - // Get environment variables - const smallPrThreshold = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); - const maxLabels = parseInt('${{ env.MAX_LABELS }}'); - const tooBigThreshold = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); + // Strategy: Merge branch detection + async function detectMergeBranch() { + const labels = new Set(); + const baseRef = context.payload.pull_request.base.ref; - // Strategy: Merge to release or beta branch - const baseRef = context.payload.pull_request.base.ref; - if (baseRef !== 'dev') { if (baseRef === 'release') { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); } - // When targeting non-dev branches, only use merge warning labels - const finalLabels = Array.from(labels); + return labels; + } + + // Strategy: Component and platform labeling + async function detectComponentPlatforms(apiData) { + const labels = new Set(); + const componentRegex = /^esphome\/components\/([^\/]+)\//; + const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`); + + for (const file of changedFiles) { + const componentMatch = file.match(componentRegex); + if (componentMatch) { + labels.add(`component: ${componentMatch[1]}`); + } + + const platformMatch = file.match(targetPlatformRegex); + if (platformMatch) { + labels.add(`platform: ${platformMatch[1]}`); + } + } + + return labels; + } + + // Strategy: New component detection + async function detectNewComponents() { + const labels = new Set(); + const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); + + for (const file of addedFiles) { + const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); + if (componentMatch) { + try { + const content = fs.readFileSync(file, 'utf8'); + if (content.includes('IS_TARGET_PLATFORM = True')) { + labels.add('new-target-platform'); + } + } catch (error) { + console.log(`Failed to read content of ${file}:`, error.message); + } + labels.add('new-component'); + } + } + + return labels; + } + + // Strategy: New platform detection + async function detectNewPlatforms(apiData) { + const labels = new Set(); + const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); + + for (const file of addedFiles) { + const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); + if (platformFileMatch) { + const [, component, platform] = platformFileMatch; + if (apiData.platformComponents.includes(platform)) { + labels.add('new-platform'); + } + } + + const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); + if (platformDirMatch) { + const [, component, platform] = platformDirMatch; + if (apiData.platformComponents.includes(platform)) { + labels.add('new-platform'); + } + } + } + + return labels; + } + + // Strategy: Core files detection + async function detectCoreChanges() { + const labels = new Set(); + const coreFiles = changedFiles.filter(file => + file.startsWith('esphome/core/') || + (file.startsWith('esphome/') && file.split('/').length === 2) + ); + + if (coreFiles.length > 0) { + labels.add('core'); + } + + return labels; + } + + // Strategy: PR size detection + async function detectPRSize() { + const labels = new Set(); + const testChanges = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); + + const nonTestChanges = totalChanges - testChanges; + + if (totalChanges <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + } + + if (nonTestChanges > TOO_BIG_THRESHOLD) { + labels.add('too-big'); + } + + return labels; + } + + // Strategy: Dashboard changes + async function detectDashboardChanges() { + const labels = new Set(); + const dashboardFiles = changedFiles.filter(file => + file.startsWith('esphome/dashboard/') || + file.startsWith('esphome/components/dashboard_import/') + ); + + if (dashboardFiles.length > 0) { + labels.add('dashboard'); + } + + return labels; + } + + // Strategy: GitHub Actions changes + async function detectGitHubActionsChanges() { + const labels = new Set(); + const githubActionsFiles = changedFiles.filter(file => + file.startsWith('.github/workflows/') + ); + + if (githubActionsFiles.length > 0) { + labels.add('github-actions'); + } + + return labels; + } + + // Strategy: Code owner detection + async function detectCodeOwner() { + const labels = new Set(); + + try { + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner, + repo, + path: 'CODEOWNERS', + }); + + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + const prAuthor = context.payload.pull_request.user.login; + + const codeownersLines = codeownersContent.split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + const codeownersRegexes = codeownersLines.map(line => { + const parts = line.split(/\s+/); + const pattern = parts[0]; + const owners = parts.slice(1); + + let regex; + if (pattern.endsWith('*')) { + const dir = pattern.slice(0, -1); + regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); + } else if (pattern.includes('*')) { + const regexPattern = pattern + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\\*/g, '.*'); + regex = new RegExp(`^${regexPattern}$`); + } else { + regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); + } + + return { regex, owners }; + }); + + for (const file of changedFiles) { + for (const { regex, owners } of codeownersRegexes) { + if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) { + labels.add('by-code-owner'); + return labels; + } + } + } + } catch (error) { + console.log('Failed to read or parse CODEOWNERS file:', error.message); + } + + return labels; + } + + // Strategy: Test detection + async function detectTests() { + const labels = new Set(); + const testFiles = changedFiles.filter(file => file.startsWith('tests/')); + + if (testFiles.length > 0) { + labels.add('has-tests'); + } + + return labels; + } + + // Strategy: Requirements detection + async function detectRequirements(allLabels) { + const labels = new Set(); + + // Check for missing tests + if ((allLabels.has('new-component') || allLabels.has('new-platform')) && !allLabels.has('has-tests')) { + labels.add('needs-tests'); + } + + // Check for missing docs + if (allLabels.has('new-component') || allLabels.has('new-platform')) { + const prBody = context.payload.pull_request.body || ''; + const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); + + if (!hasDocsLink) { + labels.add('needs-docs'); + } + } + + // Check for missing CODEOWNERS + if (allLabels.has('new-component')) { + const codeownersModified = prFiles.some(file => + file.filename === 'CODEOWNERS' && + (file.status === 'modified' || file.status === 'added') && + (file.additions || 0) > 0 + ); + + if (!codeownersModified) { + labels.add('needs-codeowners'); + } + } + + return labels; + } + + // Generate review messages + function generateReviewMessages(finalLabels) { + const messages = []; + const prAuthor = context.payload.pull_request.user.login; + + // Too big message + if (finalLabels.includes('too-big')) { + const testChanges = prFiles + .filter(file => file.filename.startsWith('tests/')) + .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); + const nonTestChanges = totalChanges - testChanges; + + const tooManyLabels = finalLabels.length > MAX_LABELS; + const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; + + let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; + + if (tooManyLabels && tooManyChanges) { + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${finalLabels.length} different components/areas.`; + } else if (tooManyLabels) { + message += `This PR affects ${finalLabels.length} different components/areas.`; + } else { + message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; + } + + message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; + message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; + + messages.push(message); + } + + // CODEOWNERS message + if (finalLabels.includes('needs-codeowners')) { + const message = `${CODEOWNERS_MARKER}\n### 👥 Code Ownership\n\n` + + `Hey there @${prAuthor},\n` + + `Thanks for submitting this pull request! Can you add yourself as a codeowner for this integration? ` + + `This way we can notify you if a bug report for this integration is reported.\n\n` + + `In \`__init__.py\` of the integration, please add:\n\n` + + `\`\`\`python\nCODEOWNERS = ["@${prAuthor}"]\n\`\`\`\n\n` + + `And run \`script/build_codeowners.py\``; + + messages.push(message); + } + + return messages; + } + + // Handle reviews + async function handleReviews(finalLabels) { + const reviewMessages = generateReviewMessages(finalLabels); + const hasReviewableLabels = finalLabels.some(label => + ['too-big', 'needs-codeowners'].includes(label) + ); + + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pr_number + }); + + const botReviews = reviews.filter(review => + review.user.type === 'Bot' && + review.state === 'CHANGES_REQUESTED' && + review.body && review.body.includes(BOT_COMMENT_MARKER) + ); + + if (hasReviewableLabels) { + const reviewBody = `${BOT_COMMENT_MARKER}\n\n${reviewMessages.join('\n\n---\n\n')}`; + + if (botReviews.length > 0) { + // Update existing review + await github.rest.pulls.updateReview({ + owner, + repo, + pull_number: pr_number, + review_id: botReviews[0].id, + body: reviewBody + }); + console.log('Updated existing bot review'); + } else { + // Create new review + await github.rest.pulls.createReview({ + owner, + repo, + pull_number: pr_number, + body: reviewBody, + event: 'REQUEST_CHANGES' + }); + console.log('Created new bot review'); + } + } else if (botReviews.length > 0) { + // Dismiss existing reviews + for (const review of botReviews) { + try { + await github.rest.pulls.dismissReview({ + owner, + repo, + pull_number: pr_number, + review_id: review.id, + message: 'Review dismissed: All requirements have been met' + }); + console.log(`Dismissed bot review ${review.id}`); + } catch (error) { + console.log(`Failed to dismiss review ${review.id}:`, error.message); + } + } + } + } + + // Main execution + const apiData = await fetchApiData(); + const baseRef = context.payload.pull_request.base.ref; + + // Early exit for non-dev branches + if (baseRef !== 'dev') { + const branchLabels = await detectMergeBranch(); + const finalLabels = Array.from(branchLabels); + console.log('Computed labels (merge branch only):', finalLabels.join(', ')); - // Add new labels + // Apply labels if (finalLabels.length > 0) { - console.log(`Adding labels: ${finalLabels.join(', ')}`); await github.rest.issues.addLabels({ owner, repo, @@ -150,13 +492,9 @@ jobs: }); } - // Remove old managed labels that are no longer needed - const labelsToRemove = managedLabels.filter(label => - !finalLabels.includes(label) - ); - + // Remove old managed labels + const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); for (const label of labelsToRemove) { - console.log(`Removing label: ${label}`); try { await github.rest.issues.removeLabel({ owner, @@ -169,324 +507,70 @@ jobs: } } - return; // Exit early, don't process other strategies + return; } - // Strategy: Component and Platform labeling - const componentRegex = /^esphome\/components\/([^\/]+)\//; - const targetPlatformRegex = new RegExp(`^esphome\/components\/(${targetPlatforms.join('|')})/`); + // Run all strategies + const [ + branchLabels, + componentLabels, + newComponentLabels, + newPlatformLabels, + coreLabels, + sizeLabels, + dashboardLabels, + actionsLabels, + codeOwnerLabels, + testLabels + ] = await Promise.all([ + detectMergeBranch(), + detectComponentPlatforms(apiData), + detectNewComponents(), + detectNewPlatforms(apiData), + detectCoreChanges(), + detectPRSize(), + detectDashboardChanges(), + detectGitHubActionsChanges(), + detectCodeOwner(), + detectTests() + ]); - for (const file of changedFiles) { - // Check for component changes - const componentMatch = file.match(componentRegex); - if (componentMatch) { - const component = componentMatch[1]; - labels.add(`component: ${component}`); - } + // Combine all labels + const allLabels = new Set([ + ...branchLabels, + ...componentLabels, + ...newComponentLabels, + ...newPlatformLabels, + ...coreLabels, + ...sizeLabels, + ...dashboardLabels, + ...actionsLabels, + ...codeOwnerLabels, + ...testLabels + ]); - // Check for target platform changes - const platformMatch = file.match(targetPlatformRegex); - if (platformMatch) { - const targetPlatform = platformMatch[1]; - labels.add(`platform: ${targetPlatform}`); - } + // Detect requirements based on all other labels + const requirementLabels = await detectRequirements(allLabels); + for (const label of requirementLabels) { + allLabels.add(label); } - // Get PR files for new component/platform detection - const { data: prFiles } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number: pr_number - }); + let finalLabels = Array.from(allLabels); - const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); + // Handle too many labels + const isMegaPR = currentLabels.includes('mega-pr'); + const tooManyLabels = finalLabels.length > MAX_LABELS; - // Calculate changes excluding root tests directory for too-big calculation - const testChanges = prFiles - .filter(file => file.filename.startsWith('tests/')) - .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); - - const nonTestChanges = totalChanges - testChanges; - console.log(`Test changes: ${testChanges}, Non-test changes: ${nonTestChanges}`); - - // Strategy: New Component detection - for (const file of addedFiles) { - // Check for new component files: esphome/components/{component}/__init__.py - const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); - if (componentMatch) { - try { - // Read the content directly from the filesystem since we have it checked out - const content = fs.readFileSync(file, 'utf8'); - - // Strategy: New Target Platform detection - if (content.includes('IS_TARGET_PLATFORM = True')) { - labels.add('new-target-platform'); - } - labels.add('new-component'); - } catch (error) { - console.log(`Failed to read content of ${file}:`, error.message); - // Fallback: assume it's a new component if we can't read the content - labels.add('new-component'); - } - } + if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { + finalLabels = ['too-big']; } - // Strategy: New Platform detection - for (const file of addedFiles) { - // Check for new platform files: esphome/components/{component}/{platform}.py - const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); - if (platformFileMatch) { - const [, component, platform] = platformFileMatch; - if (platformComponents.includes(platform)) { - labels.add('new-platform'); - } - } - - // Check for new platform files: esphome/components/{component}/{platform}/__init__.py - const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); - if (platformDirMatch) { - const [, component, platform] = platformDirMatch; - if (platformComponents.includes(platform)) { - labels.add('new-platform'); - } - } - } - - const coreFiles = changedFiles.filter(file => - file.startsWith('esphome/core/') || - (file.startsWith('esphome/') && file.split('/').length === 2) - ); - - if (coreFiles.length > 0) { - labels.add('core'); - } - - // Strategy: Small PR detection - if (totalChanges <= smallPrThreshold) { - labels.add('small-pr'); - } - - // Strategy: Dashboard changes - const dashboardFiles = changedFiles.filter(file => - file.startsWith('esphome/dashboard/') || - file.startsWith('esphome/components/dashboard_import/') - ); - - if (dashboardFiles.length > 0) { - labels.add('dashboard'); - } - - // Strategy: GitHub Actions changes - const githubActionsFiles = changedFiles.filter(file => - file.startsWith('.github/workflows/') - ); - - if (githubActionsFiles.length > 0) { - labels.add('github-actions'); - } - - // Strategy: Code Owner detection - try { - // Fetch CODEOWNERS file from the repository (in case it was changed in this PR) - const { data: codeownersFile } = await github.rest.repos.getContent({ - owner, - repo, - path: 'CODEOWNERS', - }); - - const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); - const prAuthor = context.payload.pull_request.user.login; - - // Parse CODEOWNERS file - const codeownersLines = codeownersContent.split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); - - let isCodeOwner = false; - - // Precompile CODEOWNERS patterns into regex objects - const codeownersRegexes = codeownersLines.map(line => { - const parts = line.split(/\s+/); - const pattern = parts[0]; - const owners = parts.slice(1); - - let regex; - if (pattern.endsWith('*')) { - // Directory pattern like "esphome/components/api/*" - const dir = pattern.slice(0, -1); - regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); - } else if (pattern.includes('*')) { - // Glob pattern - const regexPattern = pattern - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - .replace(/\\*/g, '.*'); - regex = new RegExp(`^${regexPattern}$`); - } else { - // Exact match - regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); - } - - return { regex, owners }; - }); - - for (const file of changedFiles) { - for (const { regex, owners } of codeownersRegexes) { - if (regex.test(file)) { - // Check if PR author is in the owners list - if (owners.some(owner => owner === `@${prAuthor}`)) { - isCodeOwner = true; - break; - } - } - } - if (isCodeOwner) break; - } - - if (isCodeOwner) { - labels.add('by-code-owner'); - } - } catch (error) { - console.log('Failed to read or parse CODEOWNERS file:', error.message); - } - - // Strategy: Test detection - const testFiles = changedFiles.filter(file => - file.startsWith('tests/') - ); - - if (testFiles.length > 0) { - labels.add('has-tests'); - } else { - // Only check for needs-tests if this is a new component or new platform - if (labels.has('new-component') || labels.has('new-platform')) { - labels.add('needs-tests'); - } - } - - // Strategy: Documentation check for new components/platforms - if (labels.has('new-component') || labels.has('new-platform')) { - const prBody = context.payload.pull_request.body || ''; - - // Look for documentation PR links - // Patterns to match: - // - https://github.com/esphome/esphome-docs/pull/1234 - // - esphome/esphome-docs#1234 - const docsPrPatterns = [ - /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, - /esphome\/esphome-docs#\d+/ - ]; - - const hasDocsLink = docsPrPatterns.some(pattern => pattern.test(prBody)); - - if (!hasDocsLink) { - labels.add('needs-docs'); - } - } - - // Convert Set to Array - let finalLabels = Array.from(labels); - console.log('Computed labels:', finalLabels.join(', ')); - // Check if PR has mega-pr label - const isMegaPR = currentLabels.includes('mega-pr'); + // Handle reviews + await handleReviews(finalLabels); - // Check if PR is too big (either too many labels or too many line changes) - const tooManyLabels = finalLabels.length > maxLabels; - const tooManyChanges = nonTestChanges > tooBigThreshold; - - if ((tooManyLabels || tooManyChanges) && !isMegaPR) { - const originalLength = finalLabels.length; - console.log(`PR is too big - Labels: ${originalLength}, Changes: ${totalChanges} (non-test: ${nonTestChanges})`); - - // Get all reviews on this PR to check for existing bot reviews - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr_number - }); - - // Check if there's already an active bot review requesting changes - const existingBotReview = reviews.find(review => - review.user.type === 'Bot' && - review.state === 'CHANGES_REQUESTED' && - review.body && review.body.includes(BOT_COMMENT_MARKER) - ); - - // If too big due to line changes only, keep original labels and add too-big - // If too big due to too many labels, replace with just too-big - if (tooManyChanges && !tooManyLabels) { - finalLabels.push('too-big'); - } else { - finalLabels = ['too-big']; - } - - // Only create a new review if there isn't already an active bot review - if (!existingBotReview) { - // Create appropriate review message - let reviewBody; - if (tooManyLabels && tooManyChanges) { - reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; - } else if (tooManyLabels) { - reviewBody = `${BOT_COMMENT_MARKER}\nThis PR affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; - } else { - reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests). Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; - } - - // Request changes on the PR - await github.rest.pulls.createReview({ - owner, - repo, - pull_number: pr_number, - body: reviewBody, - event: 'REQUEST_CHANGES' - }); - console.log('Created new "too big" review requesting changes'); - } else { - console.log('Skipping review creation - existing bot review already requesting changes'); - } - } else { - // Check if PR was previously too big but is now acceptable - const wasPreviouslyTooBig = currentLabels.includes('too-big'); - - if (wasPreviouslyTooBig || isMegaPR) { - console.log('PR is no longer too big or has mega-pr label - dismissing bot reviews'); - - // Get all reviews on this PR to find reviews to dismiss - const { data: reviews } = await github.rest.pulls.listReviews({ - owner, - repo, - pull_number: pr_number - }); - - // Find bot reviews that requested changes - const botReviews = reviews.filter(review => - review.user.type === 'Bot' && - review.state === 'CHANGES_REQUESTED' && - review.body && review.body.includes(BOT_COMMENT_MARKER) - ); - - // Dismiss bot reviews - for (const review of botReviews) { - try { - await github.rest.pulls.dismissReview({ - owner, - repo, - pull_number: pr_number, - review_id: review.id, - message: isMegaPR ? - 'Review dismissed: mega-pr label was added' : - 'Review dismissed: PR size is now acceptable' - }); - console.log(`Dismissed review ${review.id}`); - } catch (error) { - console.log(`Failed to dismiss review ${review.id}:`, error.message); - } - } - } - } - - // Add new labels + // Apply labels if (finalLabels.length > 0) { console.log(`Adding labels: ${finalLabels.join(', ')}`); await github.rest.issues.addLabels({ @@ -497,11 +581,8 @@ jobs: }); } - // Remove old managed labels that are no longer needed - const labelsToRemove = managedLabels.filter(label => - !finalLabels.includes(label) - ); - + // Remove old managed labels + const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); for (const label of labelsToRemove) { console.log(`Removing label: ${label}`); try { From ba72298a638a2240d0186ffbfaac5e21c1caf402 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:21:59 +1000 Subject: [PATCH 04/17] [factory_reset] Allow factory reset by rapid power cycle (#9749) --- esphome/components/factory_reset/__init__.py | 92 +++++++++++++++++++ .../factory_reset/factory_reset.cpp | 76 +++++++++++++++ .../components/factory_reset/factory_reset.h | 43 +++++++++ tests/components/factory_reset/common.yaml | 4 + .../factory_reset/test.bk72xx-ard.yaml | 1 + .../factory_reset/test.esp8266-ard.yaml | 3 + .../factory_reset/test.rp2040-ard.yaml | 4 +- 7 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 esphome/components/factory_reset/factory_reset.cpp create mode 100644 esphome/components/factory_reset/factory_reset.h create mode 100644 tests/components/factory_reset/test.bk72xx-ard.yaml diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index f1bcfd8c55..f3cefe6970 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -1,5 +1,97 @@ +from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg +from esphome.components.esp8266 import CONF_RESTORE_FROM_FLASH, KEY_ESP8266 +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) +from esphome.core import CORE +from esphome.final_validate import full_config CODEOWNERS = ["@anatoly-savchenkov"] factory_reset_ns = cg.esphome_ns.namespace("factory_reset") +FactoryResetComponent = factory_reset_ns.class_("FactoryResetComponent", cg.Component) +FastBootTrigger = factory_reset_ns.class_("FastBootTrigger", Trigger, cg.Component) + +CONF_MAX_DELAY = "max_delay" +CONF_RESETS_REQUIRED = "resets_required" +CONF_ON_INCREMENT = "on_increment" + + +def _validate(config): + if CONF_RESETS_REQUIRED in config: + return cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + )(config) + + if CONF_ON_INCREMENT in config: + raise cv.Invalid( + f"'{CONF_ON_INCREMENT}' requires a value for '{CONF_RESETS_REQUIRED}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FactoryResetComponent), + cv.Optional(CONF_MAX_DELAY, default="10s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=cv.TimePeriod(milliseconds=1000)), + ), + cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int, + cv.Optional(CONF_ON_INCREMENT): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FastBootTrigger), + } + ), + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +def _final_validate(config): + if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: + fconfig = full_config.get() + if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): + raise cv.Invalid( + "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + if reset_count := config.get(CONF_RESETS_REQUIRED): + var = cg.new_Pvariable( + config[CONF_ID], + reset_count, + config[CONF_MAX_DELAY].total_milliseconds, + ) + await cg.register_component(var, config) + for conf in config.get(CONF_ON_INCREMENT, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await build_automation( + trigger, + [ + (cg.uint8, "x"), + (cg.uint8, "target"), + ], + conf, + ) diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp new file mode 100644 index 0000000000..c900759d90 --- /dev/null +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -0,0 +1,76 @@ +#include "factory_reset.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#if !defined(USE_RP2040) && !defined(USE_HOST) + +namespace esphome { +namespace factory_reset { + +static const char *const TAG = "factory_reset"; +static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; + +static bool was_power_cycled() { +#ifdef USE_ESP32 + return esp_reset_reason() == ESP_RST_POWERON; +#endif +#ifdef USE_ESP8266 + auto reset_reason = EspClass::getResetReason(); + return strcasecmp(reset_reason.c_str(), "power On") == 0 || strcasecmp(reset_reason.c_str(), "external system") == 0; +#endif +#ifdef USE_LIBRETINY + auto reason = lt_get_reboot_reason(); + return reason == REBOOT_REASON_POWER || reason == REBOOT_REASON_HARDWARE; +#endif +} + +void FactoryResetComponent::dump_config() { + uint8_t count = 0; + this->flash_.load(&count); + ESP_LOGCONFIG(TAG, "Factory Reset by Reset:"); + ESP_LOGCONFIG(TAG, + " Max interval between resets %" PRIu32 " seconds\n" + " Current count: %u\n" + " Factory reset after %u resets", + this->max_interval_ / 1000, count, this->required_count_); +} + +void FactoryResetComponent::save_(uint8_t count) { + this->flash_.save(&count); + global_preferences->sync(); + this->defer([count, this] { this->increment_callback_.call(count, this->required_count_); }); +} + +void FactoryResetComponent::setup() { + this->flash_ = global_preferences->make_preference(POWER_CYCLES_KEY, true); + if (was_power_cycled()) { + uint8_t count = 0; + this->flash_.load(&count); + // this is a power on reset or external system reset + count++; + if (count == this->required_count_) { + ESP_LOGW(TAG, "Reset count reached, factory resetting"); + global_preferences->reset(); + // delay to allow log to be sent + delay(100); // NOLINT + App.safe_reboot(); // should not return + } + this->save_(count); + ESP_LOGD(TAG, "Power on reset detected, incremented count to %u", count); + this->set_timeout(this->max_interval_, [this]() { + ESP_LOGD(TAG, "No reset in the last %" PRIu32 " seconds, resetting count", this->max_interval_ / 1000); + this->save_(0); // reset count + }); + } else { + this->save_(0); // reset count if not a power cycle + } +} + +} // namespace factory_reset +} // namespace esphome + +#endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h new file mode 100644 index 0000000000..80942b29bd --- /dev/null +++ b/esphome/components/factory_reset/factory_reset.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/core/preferences.h" +#if !defined(USE_RP2040) && !defined(USE_HOST) + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome { +namespace factory_reset { +class FactoryResetComponent : public Component { + public: + FactoryResetComponent(uint8_t required_count, uint32_t max_interval) + : required_count_(required_count), max_interval_(max_interval) {} + + void dump_config() override; + void setup() override; + void add_increment_callback(std::function &&callback) { + this->increment_callback_.add(std::move(callback)); + } + + protected: + ~FactoryResetComponent() = default; + void save_(uint8_t count); + ESPPreferenceObject flash_{}; // saves the number of fast power cycles + uint8_t required_count_; // The number of boot attempts before fast boot is enabled + uint32_t max_interval_; // max interval between power cycles + CallbackManager increment_callback_{}; +}; + +class FastBootTrigger : public Trigger { + public: + explicit FastBootTrigger(FactoryResetComponent *parent) { + parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); }); + } +}; +} // namespace factory_reset +} // namespace esphome + +#endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/tests/components/factory_reset/common.yaml b/tests/components/factory_reset/common.yaml index ad3abd603e..7617dc4b81 100644 --- a/tests/components/factory_reset/common.yaml +++ b/tests/components/factory_reset/common.yaml @@ -1,3 +1,7 @@ button: - platform: factory_reset name: Reset to Factory Default Settings + +factory_reset: + resets_required: 5 + max_delay: 10s diff --git a/tests/components/factory_reset/test.bk72xx-ard.yaml b/tests/components/factory_reset/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/factory_reset/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/factory_reset/test.esp8266-ard.yaml b/tests/components/factory_reset/test.esp8266-ard.yaml index dade44d145..591a319b81 100644 --- a/tests/components/factory_reset/test.esp8266-ard.yaml +++ b/tests/components/factory_reset/test.esp8266-ard.yaml @@ -1 +1,4 @@ +esp8266: + restore_from_flash: true + <<: !include common.yaml diff --git a/tests/components/factory_reset/test.rp2040-ard.yaml b/tests/components/factory_reset/test.rp2040-ard.yaml index dade44d145..ad3abd603e 100644 --- a/tests/components/factory_reset/test.rp2040-ard.yaml +++ b/tests/components/factory_reset/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +button: + - platform: factory_reset + name: Reset to Factory Default Settings From 729f20d765d3d789d0fa5c64c3c99b8bcae198ca Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 24 Jul 2025 06:23:42 -0500 Subject: [PATCH 05/17] [gps] Patches to build on IDF, other optimizations (#9728) --- .clang-tidy.hash | 2 +- esphome/components/gps/__init__.py | 8 +- esphome/components/gps/gps.cpp | 87 +++++++++++---------- esphome/components/gps/gps.h | 12 +-- esphome/components/gps/time/gps_time.cpp | 12 +-- esphome/components/gps/time/gps_time.h | 11 +-- platformio.ini | 2 +- tests/components/gps/test.esp32-c3-idf.yaml | 5 ++ tests/components/gps/test.esp32-idf.yaml | 5 ++ 9 files changed, 73 insertions(+), 71 deletions(-) create mode 100644 tests/components/gps/test.esp32-c3-idf.yaml create mode 100644 tests/components/gps/test.esp32-idf.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 9efe5b2270..256b128cd4 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -7920671c938a5ea6a11ac4594204b5ec8f38d579c962bf1f185e8d5e3ad879be +8016e9cbe199bf1a65a0c90e7a37d768ec774f4fff70de530a9b55708af71e74 diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index 7ccd82dec3..a872cf7015 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -84,7 +84,6 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("20s")) .extend(uart.UART_DEVICE_SCHEMA), - cv.only_with_arduino, ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True) @@ -123,4 +122,9 @@ async def to_code(config): cg.add(var.set_hdop_sensor(sens)) # https://platformio.org/lib/show/1655/TinyGPSPlus - cg.add_library("mikalhart/TinyGPSPlus", "1.1.0") + # Using fork of TinyGPSPlus patched to build on ESP-IDF + cg.add_library( + "TinyGPSPlus", + None, + "https://github.com/esphome/TinyGPSPlus.git#v1.1.0", + ) diff --git a/esphome/components/gps/gps.cpp b/esphome/components/gps/gps.cpp index 9dcb351b39..cbbd36887b 100644 --- a/esphome/components/gps/gps.cpp +++ b/esphome/components/gps/gps.cpp @@ -1,5 +1,3 @@ -#ifdef USE_ARDUINO - #include "gps.h" #include "esphome/core/log.h" @@ -22,73 +20,76 @@ void GPS::dump_config() { } void GPS::update() { - if (this->latitude_sensor_ != nullptr) + if (this->latitude_sensor_ != nullptr) { this->latitude_sensor_->publish_state(this->latitude_); + } - if (this->longitude_sensor_ != nullptr) + if (this->longitude_sensor_ != nullptr) { this->longitude_sensor_->publish_state(this->longitude_); + } - if (this->speed_sensor_ != nullptr) + if (this->speed_sensor_ != nullptr) { this->speed_sensor_->publish_state(this->speed_); + } - if (this->course_sensor_ != nullptr) + if (this->course_sensor_ != nullptr) { this->course_sensor_->publish_state(this->course_); + } - if (this->altitude_sensor_ != nullptr) + if (this->altitude_sensor_ != nullptr) { this->altitude_sensor_->publish_state(this->altitude_); + } - if (this->satellites_sensor_ != nullptr) + if (this->satellites_sensor_ != nullptr) { this->satellites_sensor_->publish_state(this->satellites_); + } - if (this->hdop_sensor_ != nullptr) + if (this->hdop_sensor_ != nullptr) { this->hdop_sensor_->publish_state(this->hdop_); + } } void GPS::loop() { while (this->available() > 0 && !this->has_time_) { - if (this->tiny_gps_.encode(this->read())) { - if (this->tiny_gps_.location.isUpdated()) { - this->latitude_ = this->tiny_gps_.location.lat(); - this->longitude_ = this->tiny_gps_.location.lng(); + if (!this->tiny_gps_.encode(this->read())) { + return; + } + if (this->tiny_gps_.location.isUpdated()) { + this->latitude_ = this->tiny_gps_.location.lat(); + this->longitude_ = this->tiny_gps_.location.lng(); + ESP_LOGV(TAG, "Latitude, Longitude: %.6f°, %.6f°", this->latitude_, this->longitude_); + } - ESP_LOGD(TAG, "Location:"); - ESP_LOGD(TAG, " Lat: %.6f °", this->latitude_); - ESP_LOGD(TAG, " Lon: %.6f °", this->longitude_); - } + if (this->tiny_gps_.speed.isUpdated()) { + this->speed_ = this->tiny_gps_.speed.kmph(); + ESP_LOGV(TAG, "Speed: %.3f km/h", this->speed_); + } - if (this->tiny_gps_.speed.isUpdated()) { - this->speed_ = this->tiny_gps_.speed.kmph(); - ESP_LOGD(TAG, "Speed: %.3f km/h", this->speed_); - } + if (this->tiny_gps_.course.isUpdated()) { + this->course_ = this->tiny_gps_.course.deg(); + ESP_LOGV(TAG, "Course: %.2f°", this->course_); + } - if (this->tiny_gps_.course.isUpdated()) { - this->course_ = this->tiny_gps_.course.deg(); - ESP_LOGD(TAG, "Course: %.2f °", this->course_); - } + if (this->tiny_gps_.altitude.isUpdated()) { + this->altitude_ = this->tiny_gps_.altitude.meters(); + ESP_LOGV(TAG, "Altitude: %.2f m", this->altitude_); + } - if (this->tiny_gps_.altitude.isUpdated()) { - this->altitude_ = this->tiny_gps_.altitude.meters(); - ESP_LOGD(TAG, "Altitude: %.2f m", this->altitude_); - } + if (this->tiny_gps_.satellites.isUpdated()) { + this->satellites_ = this->tiny_gps_.satellites.value(); + ESP_LOGV(TAG, "Satellites: %d", this->satellites_); + } - if (this->tiny_gps_.satellites.isUpdated()) { - this->satellites_ = this->tiny_gps_.satellites.value(); - ESP_LOGD(TAG, "Satellites: %d", this->satellites_); - } + if (this->tiny_gps_.hdop.isUpdated()) { + this->hdop_ = this->tiny_gps_.hdop.hdop(); + ESP_LOGV(TAG, "HDOP: %.3f", this->hdop_); + } - if (this->tiny_gps_.hdop.isUpdated()) { - this->hdop_ = this->tiny_gps_.hdop.hdop(); - ESP_LOGD(TAG, "HDOP: %.3f", this->hdop_); - } - - for (auto *listener : this->listeners_) { - listener->on_update(this->tiny_gps_); - } + for (auto *listener : this->listeners_) { + listener->on_update(this->tiny_gps_); } } } } // namespace gps } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 7bc23ed1e0..36923c68be 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -1,10 +1,8 @@ #pragma once -#ifdef USE_ARDUINO - -#include "esphome/core/component.h" -#include "esphome/components/uart/uart.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" #include #include @@ -53,8 +51,9 @@ class GPS : public PollingComponent, public uart::UARTDevice { float speed_{NAN}; float course_{NAN}; float altitude_{NAN}; - uint16_t satellites_{0}; float hdop_{NAN}; + uint16_t satellites_{0}; + bool has_time_{false}; sensor::Sensor *latitude_sensor_{nullptr}; sensor::Sensor *longitude_sensor_{nullptr}; @@ -64,12 +63,9 @@ class GPS : public PollingComponent, public uart::UARTDevice { sensor::Sensor *satellites_sensor_{nullptr}; sensor::Sensor *hdop_sensor_{nullptr}; - bool has_time_{false}; TinyGPSPlus tiny_gps_; std::vector listeners_{}; }; } // namespace gps } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/gps/time/gps_time.cpp b/esphome/components/gps/time/gps_time.cpp index 0f1b989f77..cff8c1fb07 100644 --- a/esphome/components/gps/time/gps_time.cpp +++ b/esphome/components/gps/time/gps_time.cpp @@ -1,5 +1,3 @@ -#ifdef USE_ARDUINO - #include "gps_time.h" #include "esphome/core/log.h" @@ -9,12 +7,10 @@ namespace gps { static const char *const TAG = "gps.time"; void GPSTime::from_tiny_gps_(TinyGPSPlus &tiny_gps) { - if (!tiny_gps.time.isValid() || !tiny_gps.date.isValid()) - return; - if (!tiny_gps.time.isUpdated() || !tiny_gps.date.isUpdated()) - return; - if (tiny_gps.date.year() < 2019) + if (!tiny_gps.time.isValid() || !tiny_gps.date.isValid() || !tiny_gps.time.isUpdated() || + !tiny_gps.date.isUpdated() || tiny_gps.date.year() < 2025) { return; + } ESPTime val{}; val.year = tiny_gps.date.year(); @@ -34,5 +30,3 @@ void GPSTime::from_tiny_gps_(TinyGPSPlus &tiny_gps) { } // namespace gps } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index d0d1db83b5..a8414f0015 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -1,10 +1,8 @@ #pragma once -#ifdef USE_ARDUINO - -#include "esphome/core/component.h" -#include "esphome/components/time/real_time_clock.h" #include "esphome/components/gps/gps.h" +#include "esphome/components/time/real_time_clock.h" +#include "esphome/core/component.h" namespace esphome { namespace gps { @@ -13,8 +11,9 @@ class GPSTime : public time::RealTimeClock, public GPSListener { public: void update() override { this->from_tiny_gps_(this->get_tiny_gps()); }; void on_update(TinyGPSPlus &tiny_gps) override { - if (!this->has_time_) + if (!this->has_time_) { this->from_tiny_gps_(tiny_gps); + } } protected: @@ -24,5 +23,3 @@ class GPSTime : public time::RealTimeClock, public GPSListener { } // namespace gps } // namespace esphome - -#endif // USE_ARDUINO diff --git a/platformio.ini b/platformio.ini index 350f220853..1b37c9aba4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -73,7 +73,7 @@ lib_deps = heman/AsyncMqttClient-esphome@1.0.0 ; mqtt ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base fastled/FastLED@3.9.16 ; fastled_base - mikalhart/TinyGPSPlus@1.1.0 ; gps + https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps freekode/TM1651@1.0.1 ; tm1651 glmnet/Dsmr@0.7 ; dsmr rweather/Crypto@0.4.0 ; dsmr diff --git a/tests/components/gps/test.esp32-c3-idf.yaml b/tests/components/gps/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/gps/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/gps/test.esp32-idf.yaml b/tests/components/gps/test.esp32-idf.yaml new file mode 100644 index 0000000000..811f6b72a6 --- /dev/null +++ b/tests/components/gps/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +<<: !include common.yaml From 73f58dfe809df1cee24fbef7802e0e775acaaf4e Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 24 Jul 2025 13:26:21 +0200 Subject: [PATCH 06/17] [sound_level] fix spelling mistake (#9843) --- esphome/components/sound_level/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 292efadab8..8ca0feccc0 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -12,7 +12,7 @@ from esphome.const import ( UNIT_DECIBEL, ) -AUTOLOAD = ["audio"] +AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["microphone"] From 27119ef7ade501d91dc853abfc878b41ea5781b7 Mon Sep 17 00:00:00 2001 From: "@RubenKelevra" Date: Thu, 24 Jul 2025 14:43:44 +0200 Subject: [PATCH 07/17] rc522: fix buffer overflow in UID/buffer formatting helpers (#9375) --- esphome/components/rc522/rc522.cpp | 40 +++++++----------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 018b714190..fa8564f614 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -1,4 +1,5 @@ #include "rc522.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" // Based on: @@ -13,30 +14,6 @@ static const char *const TAG = "rc522"; static const uint8_t RESET_COUNT = 5; -std::string format_buffer(uint8_t *b, uint8_t len) { - char buf[32]; - int offset = 0; - for (uint8_t i = 0; i < len; i++) { - const char *format = "%02X"; - if (i + 1 < len) - format = "%02X-"; - offset += sprintf(buf + offset, format, b[i]); - } - return std::string(buf); -} - -std::string format_uid(std::vector &uid) { - char buf[32]; - int offset = 0; - for (size_t i = 0; i < uid.size(); i++) { - const char *format = "%02X"; - if (i + 1 < uid.size()) - format = "%02X-"; - offset += sprintf(buf + offset, format, uid[i]); - } - return std::string(buf); -} - void RC522::setup() { state_ = STATE_SETUP; // Pull device out of power down / reset state. @@ -215,7 +192,7 @@ void RC522::loop() { ESP_LOGV(TAG, "STATE_READ_SERIAL_DONE -> TIMEOUT (no tag present) %d", status); } else { ESP_LOGW(TAG, "Unexpected response. Read status is %d. Read bytes: %d (%s)", status, back_length_, - format_buffer(buffer_, 9).c_str()); + format_hex_pretty(buffer_, back_length_, '-', false).c_str()); } state_ = STATE_DONE; @@ -239,7 +216,7 @@ void RC522::loop() { std::vector rfid_uid(std::begin(uid_buffer_), std::begin(uid_buffer_) + uid_idx_); uid_idx_ = 0; - // ESP_LOGD(TAG, "Processing '%s'", format_uid(rfid_uid).c_str()); + // ESP_LOGD(TAG, "Processing '%s'", format_hex_pretty(rfid_uid, '-', false).c_str()); pcd_antenna_off_(); state_ = STATE_INIT; // scan again on next update bool report = true; @@ -260,13 +237,13 @@ void RC522::loop() { trigger->process(rfid_uid); if (report) { - ESP_LOGD(TAG, "Found new tag '%s'", format_uid(rfid_uid).c_str()); + ESP_LOGD(TAG, "Found new tag '%s'", format_hex_pretty(rfid_uid, '-', false).c_str()); } break; } case STATE_DONE: { if (!this->current_uid_.empty()) { - ESP_LOGV(TAG, "Tag '%s' removed", format_uid(this->current_uid_).c_str()); + ESP_LOGV(TAG, "Tag '%s' removed", format_hex_pretty(this->current_uid_, '-', false).c_str()); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(this->current_uid_); } @@ -361,7 +338,7 @@ void RC522::pcd_clear_register_bit_mask_(PcdRegister reg, ///< The register to * @return STATUS_OK on success, STATUS_??? otherwise. */ void RC522::pcd_transceive_data_(uint8_t send_len) { - ESP_LOGV(TAG, "PCD TRANSCEIVE: RX: %s", format_buffer(buffer_, send_len).c_str()); + ESP_LOGV(TAG, "PCD TRANSCEIVE: RX: %s", format_hex_pretty(buffer_, send_len, '-', false).c_str()); delayMicroseconds(1000); // we need 1 ms delay between antenna on and those communication commands send_len_ = send_len; // Prepare values for BitFramingReg @@ -435,7 +412,8 @@ RC522::StatusCode RC522::await_transceive_() { error_reg_value); // TODO: is this always due to collissions? return STATUS_ERROR; } - ESP_LOGV(TAG, "received %d bytes: %s", back_length_, format_buffer(buffer_ + send_len_, back_length_).c_str()); + ESP_LOGV(TAG, "received %d bytes: %s", back_length_, + format_hex_pretty(buffer_ + send_len_, back_length_, '-', false).c_str()); return STATUS_OK; } @@ -499,7 +477,7 @@ bool RC522BinarySensor::process(std::vector &data) { this->found_ = result; return result; } -void RC522Trigger::process(std::vector &data) { this->trigger(format_uid(data)); } +void RC522Trigger::process(std::vector &data) { this->trigger(format_hex_pretty(data, '-', false)); } } // namespace rc522 } // namespace esphome From 06eba96fdce7a69afc0d5ba2ca16542383768ef5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:12:22 -1000 Subject: [PATCH 08/17] Bump ruff from 0.12.4 to 0.12.5 (#9871) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5b45f27aa..ae3858c0ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.4 + rev: v0.12.5 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index ad5e4a3e3d..a87a4bafac 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.7 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.4 # also change in .pre-commit-config.yaml when updating +ruff==0.12.5 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From cb87f156d0b6da523cd039ade0e54e5ff95c0f36 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 24 Jul 2025 23:10:03 -0500 Subject: [PATCH 09/17] [platformio.ini] Move GPS to common lib_deps (#9883) --- .clang-tidy.hash | 2 +- platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 256b128cd4..47f68ff022 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -8016e9cbe199bf1a65a0c90e7a37d768ec774f4fff70de530a9b55708af71e74 +a0bc970a2edcf618945646316f28238c53accb6963bad3726148614d2509ede8 diff --git a/platformio.ini b/platformio.ini index 1b37c9aba4..0aaa4a6346 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,6 +40,7 @@ lib_deps = functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier kikuchan98/pngle@1.1.0 ; online_image + https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image ; This is using the repository until a new release is published to PlatformIO @@ -73,7 +74,6 @@ lib_deps = heman/AsyncMqttClient-esphome@1.0.0 ; mqtt ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base fastled/FastLED@3.9.16 ; fastled_base - https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps freekode/TM1651@1.0.1 ; tm1651 glmnet/Dsmr@0.7 ; dsmr rweather/Crypto@0.4.0 ; dsmr From ffebd30033100acc15371a1a61dc42758dd65258 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jul 2025 20:26:08 -1000 Subject: [PATCH 10/17] [ruff] Enable SIM rules and fix code simplification violations (#9872) --- esphome/__main__.py | 4 +- esphome/components/api/client.py | 6 +- esphome/components/canbus/__init__.py | 5 +- esphome/components/esp32/__init__.py | 18 ++- .../components/esp32_ble_server/__init__.py | 22 +-- esphome/components/esp32_touch/__init__.py | 5 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/ethernet/__init__.py | 2 +- esphome/components/fastled_spi/light.py | 4 +- esphome/components/graph/__init__.py | 2 +- esphome/components/http_request/__init__.py | 5 +- esphome/components/hydreon_rgxx/sensor.py | 9 +- esphome/components/i2s_audio/__init__.py | 5 +- .../i2s_audio/microphone/__init__.py | 10 +- esphome/components/ili9xxx/display.py | 7 +- esphome/components/image/__init__.py | 10 +- esphome/components/improv_serial/__init__.py | 15 +- esphome/components/ina2xx_base/__init__.py | 7 +- esphome/components/ld2450/text_sensor.py | 9 +- esphome/components/light/effects.py | 2 +- esphome/components/logger/__init__.py | 15 +- esphome/components/lvgl/__init__.py | 5 +- esphome/components/matrix_keypad/__init__.py | 7 +- esphome/components/mixer/speaker/__init__.py | 9 +- esphome/components/mlx90393/sensor.py | 12 +- esphome/components/mpr121/__init__.py | 13 +- .../components/opentherm/number/__init__.py | 4 +- esphome/components/openthread/__init__.py | 5 +- .../components/packet_transport/__init__.py | 7 +- esphome/components/pulse_counter/sensor.py | 15 +- esphome/components/qspi_dbi/display.py | 5 +- esphome/components/qwiic_pir/binary_sensor.py | 5 +- esphome/components/remote_base/__init__.py | 11 +- .../components/resampler/speaker/__init__.py | 9 +- esphome/components/rpi_dpi_rgb/display.py | 4 +- .../speaker/media_player/__init__.py | 13 +- esphome/components/spi/__init__.py | 8 +- esphome/components/sprinkler/__init__.py | 8 +- esphome/components/ssd1306_base/__init__.py | 17 ++- esphome/components/st7701s/display.py | 4 +- esphome/components/substitutions/__init__.py | 17 +-- esphome/components/sun/__init__.py | 5 +- esphome/components/sx1509/__init__.py | 12 +- esphome/components/thermostat/climate.py | 8 +- esphome/components/time/__init__.py | 4 +- esphome/components/tuya/climate/__init__.py | 19 ++- esphome/components/tuya/number/__init__.py | 14 +- esphome/components/wifi/__init__.py | 4 +- esphome/config.py | 14 +- esphome/config_validation.py | 6 +- esphome/cpp_generator.py | 5 +- esphome/dashboard/dashboard.py | 5 +- esphome/espota2.py | 5 +- esphome/helpers.py | 4 +- esphome/log.py | 2 +- esphome/mqtt.py | 5 +- esphome/platformio_api.py | 8 +- esphome/util.py | 2 +- esphome/wizard.py | 5 +- esphome/writer.py | 12 +- esphome/yaml_util.py | 9 +- pyproject.toml | 11 +- script/api_protobuf/api_protobuf.py | 6 +- script/build_language_schema.py | 26 ++-- script/helpers.py | 8 +- tests/integration/conftest.py | 24 ++- tests/integration/test_api_custom_services.py | 144 +++++++++--------- tests/integration/test_device_id_in_state.py | 4 +- .../test_scheduler_defer_cancel.py | 12 +- tests/integration/test_scheduler_null_name.py | 56 +++---- .../test_scheduler_rapid_cancellation.py | 7 +- tests/script/test_determine_jobs.py | 44 +++--- 72 files changed, 400 insertions(+), 432 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 658aef4722..cf0fed2d67 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -119,9 +119,7 @@ def mqtt_logging_enabled(mqtt_config): return False if CONF_TOPIC not in log_topic: return False - if log_topic.get(CONF_LEVEL, None) == "NONE": - return False - return True + return log_topic.get(CONF_LEVEL, None) != "NONE" def get_port_type(port): diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 2d4bc37c89..5239e07435 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -14,6 +14,8 @@ with warnings.catch_warnings(): from aioesphomeapi import APIClient, parse_log_message from aioesphomeapi.log_runner import async_run +import contextlib + from esphome.const import CONF_KEY, CONF_PASSWORD, CONF_PORT, __version__ from esphome.core import CORE @@ -66,7 +68,5 @@ async def async_run_logs(config: dict[str, Any], address: str) -> None: def run_logs(config: dict[str, Any], address: str) -> None: """Run the logs command.""" - try: + with contextlib.suppress(KeyboardInterrupt): asyncio.run(async_run_logs(config, address)) - except KeyboardInterrupt: - pass diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index cdb57fd481..e1de1eb2f2 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -22,9 +22,8 @@ def validate_id(config): if CONF_CAN_ID in config: can_id = config[CONF_CAN_ID] id_ext = config[CONF_USE_EXTENDED_ID] - if not id_ext: - if can_id > 0x7FF: - raise cv.Invalid("Standard IDs must be 11 Bit (0x000-0x7ff / 0-2047)") + if not id_ext and can_id > 0x7FF: + raise cv.Invalid("Standard IDs must be 11 Bit (0x000-0x7ff / 0-2047)") return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 5dd2288076..f0a5517185 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -953,14 +953,16 @@ def _write_idf_component_yml(): # Called by writer.py def copy_files(): - if CORE.using_arduino: - if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: - write_file_if_changed( - CORE.relative_build_path("partitions.csv"), - get_arduino_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), - ) + if ( + CORE.using_arduino + and "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES] + ): + write_file_if_changed( + CORE.relative_build_path("partitions.csv"), + get_arduino_partition_csv( + CORE.platformio_options.get("board_upload.flash_size") + ), + ) if CORE.using_esp_idf: _write_sdkconfig() _write_idf_component_yml() diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 773445a1a7..19f466eb7b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -140,20 +140,22 @@ VALUE_TYPES = { def validate_char_on_write(char_config): - if CONF_ON_WRITE in char_config: - if not char_config[CONF_WRITE] and not char_config[CONF_WRITE_NO_RESPONSE]: - raise cv.Invalid( - f"{CONF_ON_WRITE} requires the {CONF_WRITE} or {CONF_WRITE_NO_RESPONSE} property to be set" - ) + if ( + CONF_ON_WRITE in char_config + and not char_config[CONF_WRITE] + and not char_config[CONF_WRITE_NO_RESPONSE] + ): + raise cv.Invalid( + f"{CONF_ON_WRITE} requires the {CONF_WRITE} or {CONF_WRITE_NO_RESPONSE} property to be set" + ) return char_config def validate_descriptor(desc_config): - if CONF_ON_WRITE in desc_config: - if not desc_config[CONF_WRITE]: - raise cv.Invalid( - f"{CONF_ON_WRITE} requires the {CONF_WRITE} property to be set" - ) + if CONF_ON_WRITE in desc_config and not desc_config[CONF_WRITE]: + raise cv.Invalid( + f"{CONF_ON_WRITE} requires the {CONF_WRITE} property to be set" + ) if CONF_MAX_LENGTH not in desc_config: value = desc_config[CONF_VALUE][CONF_DATA] if cg.is_template(value): diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 224e301683..b6cb19ebb1 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -294,9 +294,8 @@ async def to_code(config): ) ) - if get_esp32_variant() == VARIANT_ESP32: - if CONF_IIR_FILTER in config: - cg.add(touch.set_iir_filter(config[CONF_IIR_FILTER])) + if get_esp32_variant() == VARIANT_ESP32 and CONF_IIR_FILTER in config: + cg.add(touch.set_iir_filter(config[CONF_IIR_FILTER])) if get_esp32_variant() == VARIANT_ESP32S2 or get_esp32_variant() == VARIANT_ESP32S3: if CONF_FILTER_MODE in config: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 0184c25965..33a4149571 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -245,7 +245,7 @@ async def to_code(config): if ver <= cv.Version(2, 3, 0): # No ld script support ld_script = None - if ver <= cv.Version(2, 4, 2): + elif ver <= cv.Version(2, 4, 2): # Old ld script path ld_script = ld_scripts[0] else: diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 619346b914..7a412a643d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -112,7 +112,7 @@ def _is_framework_spi_polling_mode_supported(): return True if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): return True - if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): + if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 return True return False if CORE.using_arduino: diff --git a/esphome/components/fastled_spi/light.py b/esphome/components/fastled_spi/light.py index 81d8c3b32a..e863d33846 100644 --- a/esphome/components/fastled_spi/light.py +++ b/esphome/components/fastled_spi/light.py @@ -55,9 +55,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await fastled_base.new_fastled_light(config) - rgb_order = cg.RawExpression( - config[CONF_RGB_ORDER] if CONF_RGB_ORDER in config else "RGB" - ) + rgb_order = cg.RawExpression(config.get(CONF_RGB_ORDER, "RGB")) data_rate = None if CONF_DATA_RATE in config: diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 6e8ba44bec..d72fe40dd2 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -116,7 +116,7 @@ GRAPH_SCHEMA = cv.Schema( def _relocate_fields_to_subfolder(config, subfolder, subschema): - fields = [k.schema for k in subschema.schema.keys()] + fields = [k.schema for k in subschema.schema] fields.remove(CONF_ID) if subfolder in config: # Ensure no ambiguous fields in base of config diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 0d32bc97c2..146458f53b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -70,9 +70,8 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_esp32: - if not CORE.using_esp_idf and config[CONF_VERIFY_SSL]: - error_message = "ESPHome supports certificate verification only via ESP-IDF" + if CORE.is_esp32 and not CORE.using_esp_idf and config[CONF_VERIFY_SSL]: + error_message = "ESPHome supports certificate verification only via ESP-IDF" if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 6c9f1e2877..f270b72e24 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -66,11 +66,10 @@ PROTOCOL_NAMES = { def _validate(config): for conf, models in SUPPORTED_OPTIONS.items(): - if conf in config: - if config[CONF_MODEL] not in models: - raise cv.Invalid( - f"{conf} is only available on {' and '.join(models)}, not {config[CONF_MODEL]}" - ) + if conf in config and config[CONF_MODEL] not in models: + raise cv.Invalid( + f"{conf} is only available on {' and '.join(models)}, not {config[CONF_MODEL]}" + ) return config diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 575b914605..aa0a688fa0 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -243,10 +243,7 @@ def _final_validate(_): def use_legacy(): - if CORE.using_esp_idf: - if not _use_legacy_driver: - return False - return True + return not (CORE.using_esp_idf and not _use_legacy_driver) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 7bbb94f6e3..0f02ba6c3a 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -44,9 +44,8 @@ PDM_VARIANTS = [esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S3] def _validate_esp32_variant(config): variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": - if config[CONF_PDM]: - if variant not in PDM_VARIANTS: - raise cv.Invalid(f"{variant} does not support PDM") + if config[CONF_PDM] and variant not in PDM_VARIANTS: + raise cv.Invalid(f"{variant} does not support PDM") return config if config[CONF_ADC_TYPE] == "internal": if variant not in INTERNAL_ADC_VARIANTS: @@ -122,9 +121,8 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config): - if not use_legacy(): - if config[CONF_ADC_TYPE] == "internal": - raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver.") + if not use_legacy() and config[CONF_ADC_TYPE] == "internal": + raise cv.Invalid("Internal ADC is only compatible with legacy i2s driver.") FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 14b7f15218..9588bccd55 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -138,9 +138,10 @@ def _validate(config): ]: raise cv.Invalid("Selected model can't run on ESP8266.") - if model == "CUSTOM": - if CONF_INIT_SEQUENCE not in config or CONF_DIMENSIONS not in config: - raise cv.Invalid("CUSTOM model requires init_sequence and dimensions") + if model == "CUSTOM" and ( + CONF_INIT_SEQUENCE not in config or CONF_DIMENSIONS not in config + ): + raise cv.Invalid("CUSTOM model requires init_sequence and dimensions") return config diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index f6d8673a08..99646c9f7e 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import hashlib import io import logging @@ -174,9 +175,8 @@ class ImageGrayscale(ImageEncoder): b = 1 if self.invert_alpha: b ^= 0xFF - if self.transparency == CONF_ALPHA_CHANNEL: - if a != 0xFF: - b = a + if self.transparency == CONF_ALPHA_CHANNEL and a != 0xFF: + b = a self.data[self.index] = b self.index += 1 @@ -672,10 +672,8 @@ async def write_image(config, all_frames=False): invert_alpha = config[CONF_INVERT_ALPHA] frame_count = 1 if all_frames: - try: + with contextlib.suppress(AttributeError): frame_count = image.n_frames - except AttributeError: - pass if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 544af212e0..568b200a85 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -27,14 +27,13 @@ def validate_logger(config): logger_conf = fv.full_config.get()[CONF_LOGGER] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") - if CORE.using_esp_idf: - if ( - logger_conf[CONF_HARDWARE_UART] == USB_CDC - and get_esp32_variant() == VARIANT_ESP32S3 - ): - raise cv.Invalid( - "improv_serial does not support the selected logger hardware_uart" - ) + if CORE.using_esp_idf and ( + logger_conf[CONF_HARDWARE_UART] == USB_CDC + and get_esp32_variant() == VARIANT_ESP32S3 + ): + raise cv.Invalid( + "improv_serial does not support the selected logger hardware_uart" + ) return config diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7c3d3c8899..ff70f217ec 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -78,11 +78,8 @@ def validate_model_config(config): model = config[CONF_MODEL] for key in config: - if key in SENSOR_MODEL_OPTIONS: - if model not in SENSOR_MODEL_OPTIONS[key]: - raise cv.Invalid( - f"Device model '{model}' does not support '{key}' sensor" - ) + if key in SENSOR_MODEL_OPTIONS and model not in SENSOR_MODEL_OPTIONS[key]: + raise cv.Invalid(f"Device model '{model}' does not support '{key}' sensor") tempco = config[CONF_TEMPERATURE_COEFFICIENT] if tempco > 0 and model not in ["INA228", "INA229"]: diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 6c11024b89..89b6939a29 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -56,7 +56,8 @@ async def to_code(config): sens = await text_sensor.new_text_sensor(mac_address_config) cg.add(ld2450_component.set_mac_text_sensor(sens)) for n in range(MAX_TARGETS): - if direction_conf := config.get(f"target_{n + 1}"): - if direction_config := direction_conf.get(CONF_DIRECTION): - sens = await text_sensor.new_text_sensor(direction_config) - cg.add(ld2450_component.set_direction_text_sensor(n, sens)) + if (direction_conf := config.get(f"target_{n + 1}")) and ( + direction_config := direction_conf.get(CONF_DIRECTION) + ): + sens = await text_sensor.new_text_sensor(direction_config) + cg.add(ld2450_component.set_direction_text_sensor(n, sens)) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 67c318eb8e..6f878ff5f1 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -526,7 +526,7 @@ def validate_effects(allowed_effects): errors = [] names = set() for i, x in enumerate(value): - key = next(it for it in x.keys()) + key = next(it for it in x) if key not in allowed_effects: errors.append( cv.Invalid( diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index e79396da04..cb338df466 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -346,14 +346,13 @@ async def to_code(config): if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH): cg.add_build_flag("-DUSE_STORE_LOG_STR_IN_FLASH") - if CORE.using_arduino: - if config[CONF_HARDWARE_UART] == USB_CDC: - cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=1") - if CORE.is_esp32 and get_esp32_variant() in ( - VARIANT_ESP32C3, - VARIANT_ESP32C6, - ): - cg.add_build_flag("-DARDUINO_USB_MODE=1") + if CORE.using_arduino and config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=1") + if CORE.is_esp32 and get_esp32_variant() in ( + VARIANT_ESP32C3, + VARIANT_ESP32C6, + ): + cg.add_build_flag("-DARDUINO_USB_MODE=1") if CORE.using_esp_idf: if config[CONF_HARDWARE_UART] == USB_CDC: diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b1879e6314..0cd65d298f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -201,9 +201,8 @@ def final_validation(configs): multi_conf_validate(configs) global_config = full_config.get() for config in configs: - if pages := config.get(CONF_PAGES): - if all(p[df.CONF_SKIP] for p in pages): - raise cv.Invalid("At least one page must not be skipped") + if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): + raise cv.Invalid("At least one page must not be skipped") for display_id in config[df.CONF_DISPLAYS]: path = global_config.get_path_for_id(display_id)[:-1] display = global_config.get_config_for_path(path) diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index b2bcde98ec..f7a1d622a1 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -28,9 +28,10 @@ CONF_HAS_PULLDOWNS = "has_pulldowns" def check_keys(obj): - if CONF_KEYS in obj: - if len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len(obj[CONF_COLUMNS]): - raise cv.Invalid("The number of key codes must equal the number of buttons") + if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( + obj[CONF_COLUMNS] + ): + raise cv.Invalid("The number of key codes must equal the number of buttons") return obj diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a451f2b7b4..46729f8eda 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -124,11 +124,10 @@ async def to_code(config): if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram: - if config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) for speaker_config in config[CONF_SOURCE_SPEAKERS]: source_speaker = cg.new_Pvariable(speaker_config[CONF_ID]) diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 372bb05bda..d93c379506 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -63,11 +63,13 @@ def _validate(config): raise cv.Invalid( f"{axis}: {CONF_RESOLUTION} cannot be {res} with {CONF_TEMPERATURE_COMPENSATION} enabled" ) - if config[CONF_HALLCONF] == 0xC: - if (config[CONF_OVERSAMPLING], config[CONF_FILTER]) in [(0, 0), (1, 0), (0, 1)]: - raise cv.Invalid( - f"{CONF_OVERSAMPLING}=={config[CONF_OVERSAMPLING]} and {CONF_FILTER}=={config[CONF_FILTER]} not allowed with {CONF_HALLCONF}=={config[CONF_HALLCONF]:#02x}" - ) + if config[CONF_HALLCONF] == 0xC and ( + config[CONF_OVERSAMPLING], + config[CONF_FILTER], + ) in [(0, 0), (1, 0), (0, 1)]: + raise cv.Invalid( + f"{CONF_OVERSAMPLING}=={config[CONF_OVERSAMPLING]} and {CONF_FILTER}=={config[CONF_FILTER]} not allowed with {CONF_HALLCONF}=={config[CONF_HALLCONF]:#02x}" + ) return config diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index b736a7e4f0..0bf9377275 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -56,12 +56,13 @@ def _final_validate(config): for binary_sensor in binary_sensors: if binary_sensor.get(CONF_MPR121_ID) == config[CONF_ID]: max_touch_channel = max(max_touch_channel, binary_sensor[CONF_CHANNEL]) - if max_touch_channel_in_config := config.get(CONF_MAX_TOUCH_CHANNEL): - if max_touch_channel != max_touch_channel_in_config: - raise cv.Invalid( - "Max touch channel must equal the highest binary sensor channel or be removed for auto calculation", - path=[CONF_MAX_TOUCH_CHANNEL], - ) + if ( + max_touch_channel_in_config := config.get(CONF_MAX_TOUCH_CHANNEL) + ) and max_touch_channel != max_touch_channel_in_config: + raise cv.Invalid( + "Max touch channel must equal the highest binary sensor channel or be removed for auto calculation", + path=[CONF_MAX_TOUCH_CHANNEL], + ) path = fconf.get_path_for_id(config[CONF_ID])[:-1] this_config = fconf.get_config_for_path(path) this_config[CONF_MAX_TOUCH_CHANNEL] = max_touch_channel diff --git a/esphome/components/opentherm/number/__init__.py b/esphome/components/opentherm/number/__init__.py index a65864647a..6dbc45f49b 100644 --- a/esphome/components/opentherm/number/__init__.py +++ b/esphome/components/opentherm/number/__init__.py @@ -25,9 +25,9 @@ async def new_openthermnumber(config: dict[str, Any]) -> cg.Pvariable: await cg.register_component(var, config) input.generate_setters(var, config) - if (initial_value := config.get(CONF_INITIAL_VALUE, None)) is not None: + if (initial_value := config.get(CONF_INITIAL_VALUE)) is not None: cg.add(var.set_initial_value(initial_value)) - if (restore_value := config.get(CONF_RESTORE_VALUE, None)) is not None: + if (restore_value := config.get(CONF_RESTORE_VALUE)) is not None: cg.add(var.set_restore_value(restore_value)) return var diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 25e3153d1b..2f085ebaae 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -79,9 +79,8 @@ def set_sdkconfig_options(config): "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") + if config.get(CONF_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) diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 99c1d824ca..bfb2bbc4f8 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -89,9 +89,10 @@ def validate_(config): raise cv.Invalid("No sensors or binary sensors to encrypt") elif config[CONF_ROLLING_CODE_ENABLE]: raise cv.Invalid("Rolling code requires an encryption key") - if config[CONF_PING_PONG_ENABLE]: - if not any(CONF_ENCRYPTION in p for p in config.get(CONF_PROVIDERS) or ()): - raise cv.Invalid("Ping-pong requires at least one encrypted provider") + if config[CONF_PING_PONG_ENABLE] and not any( + CONF_ENCRYPTION in p for p in config.get(CONF_PROVIDERS) or () + ): + raise cv.Invalid("Ping-pong requires at least one encrypted provider") return config diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index b330758000..dbf67fd2ad 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -49,12 +49,15 @@ def validate_internal_filter(value): [CONF_USE_PCNT], ) - if CORE.is_esp32 and use_pcnt: - if value.get(CONF_INTERNAL_FILTER).total_microseconds > 13: - raise cv.Invalid( - "Maximum internal filter value when using ESP32 hardware PCNT is 13us", - [CONF_INTERNAL_FILTER], - ) + if ( + CORE.is_esp32 + and use_pcnt + and value.get(CONF_INTERNAL_FILTER).total_microseconds > 13 + ): + raise cv.Invalid( + "Maximum internal filter value when using ESP32 hardware PCNT is 13us", + [CONF_INTERNAL_FILTER], + ) return value diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index 5b01bcc6ca..74d837a794 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -73,9 +73,8 @@ def map_sequence(value): def _validate(config): chip = DriverChip.chips[config[CONF_MODEL]] - if not chip.initsequence: - if CONF_INIT_SEQUENCE not in config: - raise cv.Invalid(f"{chip.name} model requires init_sequence") + if not chip.initsequence and CONF_INIT_SEQUENCE not in config: + raise cv.Invalid(f"{chip.name} model requires init_sequence") return config diff --git a/esphome/components/qwiic_pir/binary_sensor.py b/esphome/components/qwiic_pir/binary_sensor.py index 0a549ccb32..cd3eda5ac8 100644 --- a/esphome/components/qwiic_pir/binary_sensor.py +++ b/esphome/components/qwiic_pir/binary_sensor.py @@ -24,9 +24,8 @@ QwiicPIRComponent = qwiic_pir_ns.class_( def validate_no_debounce_unless_native(config): - if CONF_DEBOUNCE in config: - if config[CONF_DEBOUNCE_MODE] != "NATIVE": - raise cv.Invalid("debounce can only be set if debounce_mode is NATIVE") + if CONF_DEBOUNCE in config and config[CONF_DEBOUNCE_MODE] != "NATIVE": + raise cv.Invalid("debounce can only be set if debounce_mode is NATIVE") return config diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index fc824ef704..8163661c65 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1062,12 +1062,11 @@ def validate_raw_alternating(value): last_negative = None for i, val in enumerate(value): this_negative = val < 0 - if i != 0: - if this_negative == last_negative: - raise cv.Invalid( - f"Values must alternate between being positive and negative, please see index {i} and {i + 1}", - [i], - ) + if i != 0 and this_negative == last_negative: + raise cv.Invalid( + f"Values must alternate between being positive and negative, please see index {i} and {i + 1}", + [i], + ) last_negative = this_negative return value diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 9e9b32476f..def62547b2 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -90,11 +90,10 @@ async def to_code(config): if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram: - if config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 556ee5eeb4..513ed8eb58 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -140,7 +140,6 @@ async def to_code(config): cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) cg.add(var.set_pclk_inverted(config[CONF_PCLK_INVERTED])) cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY])) - index = 0 dpins = [] if CONF_RED in config[CONF_DATA_PINS]: red_pins = config[CONF_DATA_PINS][CONF_RED] @@ -158,10 +157,9 @@ async def to_code(config): dpins = dpins[8:16] + dpins[0:8] else: dpins = config[CONF_DATA_PINS] - for pin in dpins: + for index, pin in enumerate(dpins): data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - index += 1 if enable_pin := config.get(CONF_ENABLE_PIN): enable = await cg.gpio_pin_expression(enable_pin) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index dc2dae2ef1..16dcc855c3 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -204,13 +204,14 @@ def _validate_pipeline(config): def _validate_repeated_speaker(config): - if (announcement_config := config.get(CONF_ANNOUNCEMENT_PIPELINE)) and ( - media_config := config.get(CONF_MEDIA_PIPELINE) + if ( + (announcement_config := config.get(CONF_ANNOUNCEMENT_PIPELINE)) + and (media_config := config.get(CONF_MEDIA_PIPELINE)) + and announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER] ): - if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]: - raise cv.Invalid( - "The announcement and media pipelines cannot use the same speaker. Use the `mixer` speaker component to create two source speakers." - ) + raise cv.Invalid( + "The announcement and media pipelines cannot use the same speaker. Use the `mixer` speaker component to create two source speakers." + ) return config diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 065ccc2668..a436bc6dab 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -115,9 +115,7 @@ def get_target_platform(): def get_target_variant(): - return ( - CORE.data[KEY_ESP32][KEY_VARIANT] if KEY_VARIANT in CORE.data[KEY_ESP32] else "" - ) + return CORE.data[KEY_ESP32].get(KEY_VARIANT, "") # Get a list of available hardware interfaces based on target and variant. @@ -213,9 +211,7 @@ def validate_hw_pins(spi, index=-1): return False if sdo_pin_no not in pin_set[CONF_MOSI_PIN]: return False - if sdi_pin_no not in pin_set[CONF_MISO_PIN]: - return False - return True + return sdi_pin_no in pin_set[CONF_MISO_PIN] return False diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 3c94d97739..2dccb6896a 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -130,11 +130,11 @@ def validate_sprinkler(config): if ( CONF_PUMP_SWITCH_OFF_DURING_VALVE_OPEN_DELAY in sprinkler_controller and CONF_VALVE_OPEN_DELAY not in sprinkler_controller + and sprinkler_controller[CONF_PUMP_SWITCH_OFF_DURING_VALVE_OPEN_DELAY] ): - if sprinkler_controller[CONF_PUMP_SWITCH_OFF_DURING_VALVE_OPEN_DELAY]: - raise cv.Invalid( - f"{CONF_VALVE_OPEN_DELAY} must be defined when {CONF_PUMP_SWITCH_OFF_DURING_VALVE_OPEN_DELAY} is enabled" - ) + raise cv.Invalid( + f"{CONF_VALVE_OPEN_DELAY} must be defined when {CONF_PUMP_SWITCH_OFF_DURING_VALVE_OPEN_DELAY} is enabled" + ) if ( CONF_REPEAT in sprinkler_controller diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 6633b24607..9d397e396b 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -42,14 +42,15 @@ SSD1306_MODEL = cv.enum(MODELS, upper=True, space="_") def _validate(value): model = value[CONF_MODEL] - if model not in ("SSD1305_128X32", "SSD1305_128X64"): - # Contrast is default value (1.0) while brightness is not - # Indicates user is using old `brightness` option - if value[CONF_BRIGHTNESS] != 1.0 and value[CONF_CONTRAST] == 1.0: - raise cv.Invalid( - "SSD1306/SH1106 no longer accepts brightness option, " - 'please use "contrast" instead.' - ) + if ( + model not in ("SSD1305_128X32", "SSD1305_128X64") + and value[CONF_BRIGHTNESS] != 1.0 + and value[CONF_CONTRAST] == 1.0 + ): + raise cv.Invalid( + "SSD1306/SH1106 no longer accepts brightness option, " + 'please use "contrast" instead.' + ) return value diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index e2452a4c55..497740b8d2 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -189,7 +189,6 @@ async def to_code(config): cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH])) cg.add(var.set_pclk_inverted(config[CONF_PCLK_INVERTED])) cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY])) - index = 0 dpins = [] if CONF_RED in config[CONF_DATA_PINS]: red_pins = config[CONF_DATA_PINS][CONF_RED] @@ -207,10 +206,9 @@ async def to_code(config): dpins = dpins[8:16] + dpins[0:8] else: dpins = config[CONF_DATA_PINS] - for pin in dpins: + for index, pin in enumerate(dpins): data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - index += 1 if dc_pin := config.get(CONF_DC_PIN): dc = await cg.gpio_pin_expression(dc_pin) diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index e494529b9e..a96f56a045 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -49,15 +49,14 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing): try: # Invoke the jinja engine to evaluate the expression. value, err = jinja.expand(value) - if err is not None: - if not ignore_missing and "password" not in path: - _LOGGER.warning( - "Found '%s' (see %s) which looks like an expression," - " but could not resolve all the variables: %s", - value, - "->".join(str(x) for x in path), - err.message, - ) + if err is not None and not ignore_missing and "password" not in path: + _LOGGER.warning( + "Found '%s' (see %s) which looks like an expression," + " but could not resolve all the variables: %s", + value, + "->".join(str(x) for x in path), + err.message, + ) except ( TemplateError, TemplateRuntimeError, diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 5a2a39c427..c065a82958 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -1,3 +1,4 @@ +import contextlib import re from esphome import automation @@ -41,12 +42,10 @@ ELEVATION_MAP = { def elevation(value): if isinstance(value, str): - try: + with contextlib.suppress(cv.Invalid): value = ELEVATION_MAP[ cv.one_of(*ELEVATION_MAP, lower=True, space="_")(value) ] - except cv.Invalid: - pass value = cv.angle(value) return cv.float_range(min=-180, max=180)(value) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index f1b08a505a..67dc924903 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -41,11 +41,13 @@ SX1509KeyTrigger = sx1509_ns.class_( def check_keys(config): - if CONF_KEYS in config: - if len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS]: - raise cv.Invalid( - "The number of key codes must equal the number of rows * columns" - ) + if ( + CONF_KEYS in config + and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] + ): + raise cv.Invalid( + "The number of key codes must equal the number of rows * columns" + ) return config diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 0314d877a3..5d0d9442e8 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -477,11 +477,11 @@ def validate_thermostat(config): if ( CONF_ON_BOOT_RESTORE_FROM in config and config[CONF_ON_BOOT_RESTORE_FROM] is OnBootRestoreFrom.DEFAULT_PRESET + and CONF_DEFAULT_PRESET not in config ): - if CONF_DEFAULT_PRESET not in config: - raise cv.Invalid( - f"{CONF_DEFAULT_PRESET} must be defined to use {CONF_ON_BOOT_RESTORE_FROM} in DEFAULT_PRESET mode" - ) + raise cv.Invalid( + f"{CONF_DEFAULT_PRESET} must be defined to use {CONF_ON_BOOT_RESTORE_FROM} in DEFAULT_PRESET mode" + ) if config[CONF_FAN_WITH_COOLING] is True and CONF_FAN_ONLY_ACTION not in config: raise cv.Invalid( diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 58d35c4baf..63d4ba17f2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -236,7 +236,7 @@ def validate_time_at(value): def validate_cron_keys(value): if CONF_CRON in value: - for key in value.keys(): + for key in value: if key in CRON_KEYS: raise cv.Invalid(f"Cannot use option {key} when cron: is specified.") if CONF_AT in value: @@ -246,7 +246,7 @@ def validate_cron_keys(value): value.update(cron_) return value if CONF_AT in value: - for key in value.keys(): + for key in value: if key in CRON_KEYS: raise cv.Invalid(f"Cannot use option {key} when at: is specified.") at_ = value[CONF_AT] diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index 4dbdf07651..cefe5d5a4b 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -46,16 +46,15 @@ TuyaClimate = tuya_ns.class_("TuyaClimate", climate.Climate, cg.Component) def validate_temperature_multipliers(value): - if CONF_TEMPERATURE_MULTIPLIER in value: - if ( - CONF_CURRENT_TEMPERATURE_MULTIPLIER in value - or CONF_TARGET_TEMPERATURE_MULTIPLIER in value - ): - raise cv.Invalid( - f"Cannot have {CONF_TEMPERATURE_MULTIPLIER} at the same time as " - f"{CONF_CURRENT_TEMPERATURE_MULTIPLIER} and " - f"{CONF_TARGET_TEMPERATURE_MULTIPLIER}" - ) + if CONF_TEMPERATURE_MULTIPLIER in value and ( + CONF_CURRENT_TEMPERATURE_MULTIPLIER in value + or CONF_TARGET_TEMPERATURE_MULTIPLIER in value + ): + raise cv.Invalid( + f"Cannot have {CONF_TEMPERATURE_MULTIPLIER} at the same time as " + f"{CONF_CURRENT_TEMPERATURE_MULTIPLIER} and " + f"{CONF_TARGET_TEMPERATURE_MULTIPLIER}" + ) if ( CONF_CURRENT_TEMPERATURE_MULTIPLIER in value and CONF_TARGET_TEMPERATURE_MULTIPLIER not in value diff --git a/esphome/components/tuya/number/__init__.py b/esphome/components/tuya/number/__init__.py index bd57c8be14..7a61e69c5c 100644 --- a/esphome/components/tuya/number/__init__.py +++ b/esphome/components/tuya/number/__init__.py @@ -34,12 +34,14 @@ def validate_min_max(config): min_value = config[CONF_MIN_VALUE] if max_value <= min_value: raise cv.Invalid("max_value must be greater than min_value") - if hidden_config := config.get(CONF_DATAPOINT_HIDDEN): - if (initial_value := hidden_config.get(CONF_INITIAL_VALUE, None)) is not None: - if (initial_value > max_value) or (initial_value < min_value): - raise cv.Invalid( - f"{CONF_INITIAL_VALUE} must be a value between {CONF_MAX_VALUE} and {CONF_MIN_VALUE}" - ) + if ( + (hidden_config := config.get(CONF_DATAPOINT_HIDDEN)) + and (initial_value := hidden_config.get(CONF_INITIAL_VALUE, None)) is not None + and ((initial_value > max_value) or (initial_value < min_value)) + ): + raise cv.Invalid( + f"{CONF_INITIAL_VALUE} must be a value between {CONF_MAX_VALUE} and {CONF_MIN_VALUE}" + ) return config diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 61f37556ba..8cb784233f 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -442,9 +442,7 @@ async def to_code(config): if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) - elif CORE.is_esp32 and CORE.using_arduino: - cg.add_library("WiFi", None) - elif CORE.is_rp2040: + elif (CORE.is_esp32 and CORE.using_arduino) or CORE.is_rp2040: cg.add_library("WiFi", None) if CORE.is_esp32 and CORE.using_esp_idf: diff --git a/esphome/config.py b/esphome/config.py index c4aa9aea24..670cbe7233 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -198,10 +198,7 @@ class Config(OrderedDict, fv.FinalValidateConfig): self.output_paths.remove((path, domain)) def is_in_error_path(self, path: ConfigPath) -> bool: - for err in self.errors: - if _path_begins_with(err.path, path): - return True - return False + return any(_path_begins_with(err.path, path) for err in self.errors) def set_by_path(self, path, value): conf = self @@ -224,7 +221,7 @@ class Config(OrderedDict, fv.FinalValidateConfig): for index, path_item in enumerate(path): try: if path_item in data: - key_data = [x for x in data.keys() if x == path_item][0] + key_data = [x for x in data if x == path_item][0] if isinstance(key_data, ESPHomeDataBase): doc_range = key_data.esp_range if get_key and index == len(path) - 1: @@ -1081,7 +1078,7 @@ def dump_dict( ret += "{}" multiline = False - for k in conf.keys(): + for k in conf: path_ = path + [k] error = config.get_error_for_path(path_) if error is not None: @@ -1097,10 +1094,7 @@ def dump_dict( msg = f"\n{indent(msg)}" if inf is not None: - if m: - msg = f" {inf}{msg}" - else: - msg = f"{msg} {inf}" + msg = f" {inf}{msg}" if m else f"{msg} {inf}" ret += f"{st + msg}\n" elif isinstance(conf, str): if is_secret(conf): diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 756464b563..1a4976e235 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import datetime from ipaddress import ( @@ -2113,10 +2113,8 @@ def require_esphome_version(year, month, patch): @contextmanager def suppress_invalid(): - try: + with suppress(vol.Invalid): yield - except vol.Invalid: - pass GIT_SCHEMA = Schema( diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 060dd36f8f..72aadcb139 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1037,10 +1037,7 @@ class MockObjClass(MockObj): def inherits_from(self, other: "MockObjClass") -> bool: if str(self) == str(other): return True - for parent in self._parents: - if str(parent) == str(other): - return True - return False + return any(str(parent) == str(other) for parent in self._parents) def template(self, *args: SafeExpType) -> "MockObjClass": if len(args) != 1 or not isinstance(args[0], TemplateArguments): diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 9de2d39ce2..81c10763e7 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from asyncio import events from concurrent.futures import ThreadPoolExecutor +import contextlib import logging import os import socket @@ -125,10 +126,8 @@ def start_dashboard(args) -> None: asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose)) - try: + with contextlib.suppress(KeyboardInterrupt): asyncio.run(async_start(args)) - except KeyboardInterrupt: - pass async def async_start(args) -> None: diff --git a/esphome/espota2.py b/esphome/espota2.py index 4f2a08fb4a..279bafee8e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -88,10 +88,7 @@ def recv_decode(sock, amount, decode=True): def receive_exactly(sock, amount, msg, expect, decode=True): - if decode: - data = [] - else: - data = b"" + data = [] if decode else b"" try: data += recv_decode(sock, 1, decode=decode) diff --git a/esphome/helpers.py b/esphome/helpers.py index bf0e3b5cf7..d1f3080e34 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -96,9 +96,7 @@ def cpp_string_escape(string, encoding="utf-8"): def _should_escape(byte: int) -> bool: if not 32 <= byte < 127: return True - if byte in (ord("\\"), ord('"')): - return True - return False + return byte in (ord("\\"), ord('"')) if isinstance(string, str): string = string.encode(encoding) diff --git a/esphome/log.py b/esphome/log.py index 0e91eb32c2..8831b1b2b3 100644 --- a/esphome/log.py +++ b/esphome/log.py @@ -61,7 +61,7 @@ class ESPHomeLogFormatter(logging.Formatter): }.get(record.levelname, "") message = f"{prefix}{formatted}{AnsiStyle.RESET_ALL.value}" if CORE.dashboard: - try: + try: # noqa: SIM105 message = message.replace("\033", "\\033") except UnicodeEncodeError: pass diff --git a/esphome/mqtt.py b/esphome/mqtt.py index a420b5ba7f..acfa8a0926 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -1,3 +1,4 @@ +import contextlib from datetime import datetime import hashlib import json @@ -52,10 +53,8 @@ def initialize( client = prepare( config, subscriptions, on_message, on_connect, username, password, client_id ) - try: + with contextlib.suppress(KeyboardInterrupt): client.loop_forever() - except KeyboardInterrupt: - pass return 0 diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index e34ac028f8..7415ec9794 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -131,9 +131,11 @@ def _load_idedata(config): temp_idedata = Path(CORE.relative_internal_path("idedata", f"{CORE.name}.json")) changed = False - if not platformio_ini.is_file() or not temp_idedata.is_file(): - changed = True - elif platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime: + if ( + not platformio_ini.is_file() + or not temp_idedata.is_file() + or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime + ): changed = True if not changed: diff --git a/esphome/util.py b/esphome/util.py index 79cb630200..3b346371bc 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -59,7 +59,7 @@ def safe_print(message="", end="\n"): from esphome.core import CORE if CORE.dashboard: - try: + try: # noqa: SIM105 message = message.replace("\033", "\\033") except UnicodeEncodeError: pass diff --git a/esphome/wizard.py b/esphome/wizard.py index 6f7cbd1ff4..8602e90222 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -116,10 +116,7 @@ def wizard_file(**kwargs): kwargs["fallback_name"] = ap_name kwargs["fallback_psk"] = "".join(random.choice(letters) for _ in range(12)) - if kwargs.get("friendly_name"): - base = BASE_CONFIG_FRIENDLY - else: - base = BASE_CONFIG + base = BASE_CONFIG_FRIENDLY if kwargs.get("friendly_name") else BASE_CONFIG config = base.format(**kwargs) diff --git a/esphome/writer.py b/esphome/writer.py index d2ec112ec8..2c2e00b513 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -86,21 +86,17 @@ def storage_should_clean(old: StorageJSON, new: StorageJSON) -> bool: if old.src_version != new.src_version: return True - if old.build_path != new.build_path: - return True - - return False + return old.build_path != new.build_path def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool: if ( old.loaded_integrations != new.loaded_integrations or old.loaded_platforms != new.loaded_platforms - ): - if new.core_platform == PLATFORM_ESP32: - from esphome.components.esp32 import FRAMEWORK_ESP_IDF + ) and new.core_platform == PLATFORM_ESP32: + from esphome.components.esp32 import FRAMEWORK_ESP_IDF - return new.framework == FRAMEWORK_ESP_IDF + return new.framework == FRAMEWORK_ESP_IDF return False diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e52fc9e788..33a56fc158 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -56,9 +56,12 @@ class ESPHomeDataBase: def from_node(self, node): # pylint: disable=attribute-defined-outside-init self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark) - if isinstance(node, yaml.ScalarNode): - if node.style is not None and node.style in "|>": - self._content_offset = 1 + if ( + isinstance(node, yaml.ScalarNode) + and node.style is not None + and node.style in "|>" + ): + self._content_offset = 1 def from_database(self, database): # pylint: disable=attribute-defined-outside-init diff --git a/pyproject.toml b/pyproject.toml index 5d48779ad5..971b9fbf74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,11 +111,12 @@ exclude = ['generated'] [tool.ruff.lint] select = [ - "E", # pycodestyle - "F", # pyflakes/autoflake - "I", # isort - "PL", # pylint - "UP", # pyupgrade + "E", # pycodestyle + "F", # pyflakes/autoflake + "I", # isort + "PL", # pylint + "SIM", # flake8-simplify + "UP", # pyupgrade ] ignore = [ diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 424565a893..92c85d2366 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -61,9 +61,7 @@ def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" lines = [] for line in text.splitlines(): - if line == "": - p = "" - elif line.startswith("#ifdef") or line.startswith("#endif"): + if line == "" or line.startswith("#ifdef") or line.startswith("#endif"): p = "" else: p = padding @@ -2385,7 +2383,7 @@ static const char *const TAG = "api.service"; needs_conn = get_opt(m, pb.needs_setup_connection, True) needs_auth = get_opt(m, pb.needs_authentication, True) - ifdef = message_ifdef_map.get(inp, ifdefs.get(inp, None)) + ifdef = message_ifdef_map.get(inp, ifdefs.get(inp)) if ifdef is not None: hpp += f"#ifdef {ifdef}\n" diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 960c35ca0f..c114d15315 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -71,11 +71,13 @@ def get_component_names(): skip_components = [] for d in os.listdir(CORE_COMPONENTS_PATH): - if not d.startswith("__") and os.path.isdir( - os.path.join(CORE_COMPONENTS_PATH, d) + if ( + not d.startswith("__") + and os.path.isdir(os.path.join(CORE_COMPONENTS_PATH, d)) + and d not in component_names + and d not in skip_components ): - if d not in component_names and d not in skip_components: - component_names.append(d) + component_names.append(d) return sorted(component_names) @@ -139,11 +141,10 @@ def register_module_schemas(key, module, manifest=None): for name, schema in module_schemas(module): register_known_schema(key, name, schema) - if manifest: + if manifest and manifest.multi_conf and S_CONFIG_SCHEMA in output[key][S_SCHEMAS]: # Multi conf should allow list of components # not sure about 2nd part of the if, might be useless config (e.g. as3935) - if manifest.multi_conf and S_CONFIG_SCHEMA in output[key][S_SCHEMAS]: - output[key][S_SCHEMAS][S_CONFIG_SCHEMA]["is_list"] = True + output[key][S_SCHEMAS][S_CONFIG_SCHEMA]["is_list"] = True def register_known_schema(module, name, schema): @@ -230,7 +231,7 @@ def add_module_registries(domain, module): reg_type = attr_name.partition("_")[0].lower() found_registries[repr(attr_obj)] = f"{domain}.{reg_type}" - for name in attr_obj.keys(): + for name in attr_obj: if "." not in name: reg_entry_name = name else: @@ -700,7 +701,7 @@ def is_convertible_schema(schema): if repr(schema) in ejs.registry_schemas: return True if isinstance(schema, dict): - for k in schema.keys(): + for k in schema: if isinstance(k, (cv.Required, cv.Optional)): return True return False @@ -818,7 +819,7 @@ def convert(schema, config_var, path): elif schema_type == "automation": extra_schema = None config_var[S_TYPE] = "trigger" - if automation.AUTOMATION_SCHEMA == ejs.extended_schemas[repr(data)][0]: + if ejs.extended_schemas[repr(data)][0] == automation.AUTOMATION_SCHEMA: extra_schema = ejs.extended_schemas[repr(data)][1] if ( extra_schema is not None and len(extra_schema) > 1 @@ -926,9 +927,8 @@ def convert(schema, config_var, path): config = convert_config(schema_type, path + "/type_" + schema_key) types[schema_key] = config["schema"] - elif DUMP_UNKNOWN: - if S_TYPE not in config_var: - config_var["unknown"] = repr_schema + elif DUMP_UNKNOWN and S_TYPE not in config_var: + config_var["unknown"] = repr_schema if DUMP_PATH: config_var["path"] = path diff --git a/script/helpers.py b/script/helpers.py index 9032451b4f..4903521e2d 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -365,9 +365,11 @@ def load_idedata(environment: str) -> dict[str, Any]: platformio_ini = Path(root_path) / "platformio.ini" temp_idedata = Path(temp_folder) / f"idedata-{environment}.json" changed = False - if not platformio_ini.is_file() or not temp_idedata.is_file(): - changed = True - elif platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime: + if ( + not platformio_ini.is_file() + or not temp_idedata.is_file() + or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime + ): changed = True if "idf" in environment: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6e2f398f49..46eb6c88e2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -183,19 +183,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s content = content.replace("api:", f"api:\n port: {unused_tcp_port}") # Add debug build flags for integration tests to enable assertions - if "esphome:" in content: - # Check if platformio_options already exists - if "platformio_options:" not in content: - # Add platformio_options with debug flags after esphome: - content = content.replace( - "esphome:", - "esphome:\n" - " # Enable assertions for integration tests\n" - " platformio_options:\n" - " build_flags:\n" - ' - "-DDEBUG" # Enable assert() statements\n' - ' - "-g" # Add debug symbols', - ) + if "esphome:" in content and "platformio_options:" not in content: + # Add platformio_options with debug flags after esphome: + content = content.replace( + "esphome:", + "esphome:\n" + " # Enable assertions for integration tests\n" + " platformio_options:\n" + " build_flags:\n" + ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-g" # Add debug symbols', + ) return content diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index 2862dcc900..9ae4cdcb5d 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -59,86 +59,86 @@ async def test_api_custom_services( custom_arrays_future.set_result(True) # Run with log monitoring - async with run_compiled(yaml_config, line_callback=check_output): - async with api_client_connected() as client: - # Verify device info - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "api-custom-services-test" + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "api-custom-services-test" - # List services - _, services = await client.list_entities_services() + # List services + _, services = await client.list_entities_services() - # Should have 4 services: 1 YAML + 3 CustomAPIDevice - assert len(services) == 4, f"Expected 4 services, found {len(services)}" + # Should have 4 services: 1 YAML + 3 CustomAPIDevice + assert len(services) == 4, f"Expected 4 services, found {len(services)}" - # Find our services - yaml_service: UserService | None = None - custom_service: UserService | None = None - custom_args_service: UserService | None = None - custom_arrays_service: UserService | None = None + # Find our services + yaml_service: UserService | None = None + custom_service: UserService | None = None + custom_args_service: UserService | None = None + custom_arrays_service: UserService | None = None - for service in services: - if service.name == "test_yaml_service": - yaml_service = service - elif service.name == "custom_test_service": - custom_service = service - elif service.name == "custom_service_with_args": - custom_args_service = service - elif service.name == "custom_service_with_arrays": - custom_arrays_service = service + for service in services: + if service.name == "test_yaml_service": + yaml_service = service + elif service.name == "custom_test_service": + custom_service = service + elif service.name == "custom_service_with_args": + custom_args_service = service + elif service.name == "custom_service_with_arrays": + custom_arrays_service = service - assert yaml_service is not None, "test_yaml_service not found" - assert custom_service is not None, "custom_test_service not found" - assert custom_args_service is not None, "custom_service_with_args not found" - assert custom_arrays_service is not None, ( - "custom_service_with_arrays not found" - ) + assert yaml_service is not None, "test_yaml_service not found" + assert custom_service is not None, "custom_test_service not found" + assert custom_args_service is not None, "custom_service_with_args not found" + assert custom_arrays_service is not None, "custom_service_with_arrays not found" - # Test YAML service - client.execute_service(yaml_service, {}) - await asyncio.wait_for(yaml_service_future, timeout=5.0) + # Test YAML service + client.execute_service(yaml_service, {}) + await asyncio.wait_for(yaml_service_future, timeout=5.0) - # Test simple CustomAPIDevice service - client.execute_service(custom_service, {}) - await asyncio.wait_for(custom_service_future, timeout=5.0) + # Test simple CustomAPIDevice service + client.execute_service(custom_service, {}) + await asyncio.wait_for(custom_service_future, timeout=5.0) - # Verify custom_args_service arguments - assert len(custom_args_service.args) == 4 - arg_types = {arg.name: arg.type for arg in custom_args_service.args} - assert arg_types["arg_string"] == UserServiceArgType.STRING - assert arg_types["arg_int"] == UserServiceArgType.INT - assert arg_types["arg_bool"] == UserServiceArgType.BOOL - assert arg_types["arg_float"] == UserServiceArgType.FLOAT + # Verify custom_args_service arguments + assert len(custom_args_service.args) == 4 + arg_types = {arg.name: arg.type for arg in custom_args_service.args} + assert arg_types["arg_string"] == UserServiceArgType.STRING + assert arg_types["arg_int"] == UserServiceArgType.INT + assert arg_types["arg_bool"] == UserServiceArgType.BOOL + assert arg_types["arg_float"] == UserServiceArgType.FLOAT - # Test CustomAPIDevice service with arguments - client.execute_service( - custom_args_service, - { - "arg_string": "test_string", - "arg_int": 456, - "arg_bool": True, - "arg_float": 78.9, - }, - ) - await asyncio.wait_for(custom_args_future, timeout=5.0) + # Test CustomAPIDevice service with arguments + client.execute_service( + custom_args_service, + { + "arg_string": "test_string", + "arg_int": 456, + "arg_bool": True, + "arg_float": 78.9, + }, + ) + await asyncio.wait_for(custom_args_future, timeout=5.0) - # Verify array service arguments - assert len(custom_arrays_service.args) == 4 - array_arg_types = {arg.name: arg.type for arg in custom_arrays_service.args} - assert array_arg_types["bool_array"] == UserServiceArgType.BOOL_ARRAY - assert array_arg_types["int_array"] == UserServiceArgType.INT_ARRAY - assert array_arg_types["float_array"] == UserServiceArgType.FLOAT_ARRAY - assert array_arg_types["string_array"] == UserServiceArgType.STRING_ARRAY + # Verify array service arguments + assert len(custom_arrays_service.args) == 4 + array_arg_types = {arg.name: arg.type for arg in custom_arrays_service.args} + assert array_arg_types["bool_array"] == UserServiceArgType.BOOL_ARRAY + assert array_arg_types["int_array"] == UserServiceArgType.INT_ARRAY + assert array_arg_types["float_array"] == UserServiceArgType.FLOAT_ARRAY + assert array_arg_types["string_array"] == UserServiceArgType.STRING_ARRAY - # Test CustomAPIDevice service with arrays - client.execute_service( - custom_arrays_service, - { - "bool_array": [True, False], - "int_array": [1, 2, 3], - "float_array": [1.1, 2.2], - "string_array": ["hello", "world"], - }, - ) - await asyncio.wait_for(custom_arrays_future, timeout=5.0) + # Test CustomAPIDevice service with arrays + client.execute_service( + custom_arrays_service, + { + "bool_array": [True, False], + "int_array": [1, 2, 3], + "float_array": [1.1, 2.2], + "string_array": ["hello", "world"], + }, + ) + await asyncio.wait_for(custom_arrays_future, timeout=5.0) diff --git a/tests/integration/test_device_id_in_state.py b/tests/integration/test_device_id_in_state.py index fb61569e59..51088bcbf7 100644 --- a/tests/integration/test_device_id_in_state.py +++ b/tests/integration/test_device_id_in_state.py @@ -47,9 +47,7 @@ async def test_device_id_in_state( entity_device_mapping[entity.key] = device_ids["Humidity Monitor"] elif entity.name == "Motion Detected": entity_device_mapping[entity.key] = device_ids["Motion Sensor"] - elif entity.name == "Temperature Monitor Power": - entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] - elif entity.name == "Temperature Status": + elif entity.name in {"Temperature Monitor Power", "Temperature Status"}: entity_device_mapping[entity.key] = device_ids["Temperature Monitor"] elif entity.name == "Motion Light": entity_device_mapping[entity.key] = device_ids["Motion Sensor"] diff --git a/tests/integration/test_scheduler_defer_cancel.py b/tests/integration/test_scheduler_defer_cancel.py index 7bce0eda54..34c46bab82 100644 --- a/tests/integration/test_scheduler_defer_cancel.py +++ b/tests/integration/test_scheduler_defer_cancel.py @@ -70,11 +70,13 @@ async def test_scheduler_defer_cancel( test_complete_future.set_result(True) return - if state.key == test_result_entity.key and not test_result_future.done(): - # Event type should be "defer_executed_X" where X is the defer number - if state.event_type.startswith("defer_executed_"): - defer_num = int(state.event_type.split("_")[-1]) - test_result_future.set_result(defer_num) + if ( + state.key == test_result_entity.key + and not test_result_future.done() + and state.event_type.startswith("defer_executed_") + ): + defer_num = int(state.event_type.split("_")[-1]) + test_result_future.set_result(defer_num) client.subscribe_states(on_state) diff --git a/tests/integration/test_scheduler_null_name.py b/tests/integration/test_scheduler_null_name.py index 66e25d4a11..75864ea2d2 100644 --- a/tests/integration/test_scheduler_null_name.py +++ b/tests/integration/test_scheduler_null_name.py @@ -27,33 +27,33 @@ async def test_scheduler_null_name( if not test_complete_future.done() and test_complete_pattern.search(line): test_complete_future.set_result(True) - async with run_compiled(yaml_config, line_callback=check_output): - async with api_client_connected() as client: - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-null-name" + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify we can connect + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-null-name" - # List services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 + # List services + _, services = await asyncio.wait_for( + client.list_entities_services(), timeout=5.0 + ) + + # Find our test service + test_null_name_service = next( + (s for s in services if s.name == "test_null_name"), None + ) + assert test_null_name_service is not None, "test_null_name service not found" + + # Execute the test + client.execute_service(test_null_name_service, {}) + + # Wait for test completion + try: + await asyncio.wait_for(test_complete_future, timeout=10.0) + except TimeoutError: + pytest.fail( + "Test did not complete within timeout - likely crashed due to NULL name" ) - - # Find our test service - test_null_name_service = next( - (s for s in services if s.name == "test_null_name"), None - ) - assert test_null_name_service is not None, ( - "test_null_name service not found" - ) - - # Execute the test - client.execute_service(test_null_name_service, {}) - - # Wait for test completion - try: - await asyncio.wait_for(test_complete_future, timeout=10.0) - except TimeoutError: - pytest.fail( - "Test did not complete within timeout - likely crashed due to NULL name" - ) diff --git a/tests/integration/test_scheduler_rapid_cancellation.py b/tests/integration/test_scheduler_rapid_cancellation.py index 6b6277c752..1b7da32aaa 100644 --- a/tests/integration/test_scheduler_rapid_cancellation.py +++ b/tests/integration/test_scheduler_rapid_cancellation.py @@ -61,9 +61,10 @@ async def test_scheduler_rapid_cancellation( elif "Total executed:" in line: if match := re.search(r"Total executed: (\d+)", line): test_stats["final_executed"] = int(match.group(1)) - elif "Implicit cancellations (replaced):" in line: - if match := re.search(r"Implicit cancellations \(replaced\): (\d+)", line): - test_stats["final_implicit_cancellations"] = int(match.group(1)) + elif "Implicit cancellations (replaced):" in line and ( + match := re.search(r"Implicit cancellations \(replaced\): (\d+)", line) + ): + test_stats["final_implicit_cancellations"] = int(match.group(1)) # Check for crash indicators if any( diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 84be7344c3..7200afc2ee 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -146,9 +146,11 @@ def test_main_list_components_fails( mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd") # Run main function with mocked argv - should raise - with patch("sys.argv", ["determine-jobs.py"]): - with pytest.raises(subprocess.CalledProcessError): - determine_jobs.main() + with ( + patch("sys.argv", ["determine-jobs.py"]), + pytest.raises(subprocess.CalledProcessError), + ): + determine_jobs.main() def test_main_with_branch_argument( @@ -243,17 +245,21 @@ def test_should_run_integration_tests_with_branch() -> None: def test_should_run_integration_tests_component_dependency() -> None: """Test that integration tests run when components used in fixtures change.""" - with patch.object( - determine_jobs, "changed_files", return_value=["esphome/components/api/api.cpp"] - ): - with patch.object( + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/api/api.cpp"], + ), + patch.object( determine_jobs, "get_components_from_integration_fixtures" - ) as mock_fixtures: - mock_fixtures.return_value = {"api", "sensor"} - with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: - mock_deps.return_value = {"api", "sensor", "network"} - result = determine_jobs.should_run_integration_tests() - assert result is True + ) as mock_fixtures, + ): + mock_fixtures.return_value = {"api", "sensor"} + with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: + mock_deps.return_value = {"api", "sensor", "network"} + result = determine_jobs.should_run_integration_tests() + assert result is True @pytest.mark.parametrize( @@ -272,12 +278,14 @@ def test_should_run_clang_tidy( expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with patch.object(determine_jobs, "changed_files", return_value=changed_files): + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch("subprocess.run") as mock_run, + ): # Test with hash check returning specific code - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=check_returncode) - result = determine_jobs.should_run_clang_tidy() - assert result == expected_result + mock_run.return_value = Mock(returncode=check_returncode) + result = determine_jobs.should_run_clang_tidy() + assert result == expected_result def test_should_run_clang_tidy_hash_check_exception() -> None: From e79589efeeabb7bdc8203aa8c7ee3aa7f9900ded Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 25 Jul 2025 01:31:32 -0500 Subject: [PATCH 11/17] [platformio.ini] Add GPS to nrf52-zephyr lib_deps (#9884) --- .clang-tidy.hash | 2 +- platformio.ini | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 47f68ff022..02aad99327 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a0bc970a2edcf618945646316f28238c53accb6963bad3726148614d2509ede8 +2dc5fb2038fb2081e8f27fa19c4d5e9d107ceabda951e68f2d6d296edfb54613 diff --git a/platformio.ini b/platformio.ini index 0aaa4a6346..8b24d18d0d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -239,6 +239,7 @@ lib_deps = wjtje/qr-code-generator-library@1.7.0 ; qr_code pavlodn/HaierProtocol@0.9.31 ; haier functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 + https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library lvgl/lvgl@8.4.0 ; lvgl From c5c0237a4bfd556c353a3723eac201c315cb1255 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 25 Jul 2025 20:16:23 +1200 Subject: [PATCH 12/17] Remove redundant platformio environments (#9886) --- .clang-tidy.hash | 2 +- platformio.ini | 53 ++---------------------------------------------- 2 files changed, 3 insertions(+), 52 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 02aad99327..316f43e706 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -2dc5fb2038fb2081e8f27fa19c4d5e9d107ceabda951e68f2d6d296edfb54613 +32b0db73b3ae01ba18c9cbb1dabbd8156bc14dded500471919bd0a3dc33916e0 diff --git a/platformio.ini b/platformio.ini index 8b24d18d0d..bf0754ead3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -180,13 +180,6 @@ build_unflags = ${common.build_unflags} extra_scripts = post:esphome/components/esp32/post_build.py.script -; This are common settings for the ESP32 using the latest ESP-IDF version. -[common:esp32-idf-5_3] -extends = common:esp32-idf -platform = platformio/espressif32@6.8.0 -platform_packages = - platformio/framework-espidf@~3.50300.0 - ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] extends = common:arduino @@ -299,17 +292,6 @@ build_flags = build_unflags = ${common.build_unflags} -[env:esp32-idf-5_3] -extends = common:esp32-idf-5_3 -board = esp32dev -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32 -build_unflags = - ${common.build_unflags} - [env:esp32-idf-tidy] extends = common:esp32-idf board = esp32dev @@ -354,17 +336,6 @@ build_flags = build_unflags = ${common.build_unflags} -[env:esp32c3-idf-5_3] -extends = common:esp32-idf-5_3 -board = esp32-c3-devkitm-1 -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32c3-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32C3 -build_unflags = - ${common.build_unflags} - [env:esp32c3-idf-tidy] extends = common:esp32-idf board = esp32-c3-devkitm-1 @@ -420,17 +391,6 @@ build_flags = build_unflags = ${common.build_unflags} -[env:esp32s2-idf-5_3] -extends = common:esp32-idf-5_3 -board = esp32-s2-kaluga-1 -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32s2-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32S2 -build_unflags = - ${common.build_unflags} - [env:esp32s2-idf-tidy] extends = common:esp32-idf board = esp32-s2-kaluga-1 @@ -475,17 +435,6 @@ build_flags = build_unflags = ${common.build_unflags} -[env:esp32s3-idf-5_3] -extends = common:esp32-idf-5_3 -board = esp32-s3-devkitc-1 -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32s3-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32S3 -build_unflags = - ${common.build_unflags} - [env:esp32s3-idf-tidy] extends = common:esp32-idf board = esp32-s3-devkitc-1 @@ -566,6 +515,8 @@ build_flags = build_unflags = ${common.build_unflags} +;;;;;;;; Host ;;;;;;;; + [env:host] extends = common platform = platformio/native From 773a8b8fb70b530d7a33a37f6a026b83be71934e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 25 Jul 2025 21:14:28 +1200 Subject: [PATCH 13/17] [CI] Better mega-pr label handling (#9888) --- .github/workflows/auto-label-pr.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 17c77a1920..f855044d31 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -14,6 +14,7 @@ env: SMALL_PR_THRESHOLD: 30 MAX_LABELS: 15 TOO_BIG_THRESHOLD: 1000 + COMPONENT_LABEL_THRESHOLD: 10 jobs: label: @@ -41,6 +42,7 @@ jobs: const SMALL_PR_THRESHOLD = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); const MAX_LABELS = parseInt('${{ env.MAX_LABELS }}'); const TOO_BIG_THRESHOLD = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); + const COMPONENT_LABEL_THRESHOLD = parseInt('${{ env.COMPONENT_LABEL_THRESHOLD }}'); const BOT_COMMENT_MARKER = ''; const CODEOWNERS_MARKER = ''; const TOO_BIG_MARKER = ''; @@ -84,6 +86,9 @@ jobs: label.startsWith('component: ') || MANAGED_LABELS.includes(label) ); + // Check for mega-PR early - if present, skip most automatic labeling + const isMegaPR = currentLabels.includes('mega-pr'); + const { data: prFiles } = await github.rest.pulls.listFiles({ owner, repo, @@ -97,6 +102,9 @@ jobs: console.log('Current labels:', currentLabels.join(', ')); console.log('Changed files:', changedFiles.length); console.log('Total changes:', totalChanges); + if (isMegaPR) { + console.log('Mega-PR detected - applying limited labeling logic'); + } // Fetch API data async function fetchApiData() { @@ -225,7 +233,8 @@ jobs: labels.add('small-pr'); } - if (nonTestChanges > TOO_BIG_THRESHOLD) { + // Don't add too-big if mega-pr label is already present + if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { labels.add('too-big'); } @@ -557,8 +566,16 @@ jobs: let finalLabels = Array.from(allLabels); - // Handle too many labels - const isMegaPR = currentLabels.includes('mega-pr'); + // For mega-PRs, exclude component labels if there are too many + if (isMegaPR) { + const componentLabels = finalLabels.filter(label => label.startsWith('component: ')); + if (componentLabels.length > COMPONENT_LABEL_THRESHOLD) { + finalLabels = finalLabels.filter(label => !label.startsWith('component: ')); + console.log(`Mega-PR detected - excluding ${componentLabels.length} component labels (threshold: ${COMPONENT_LABEL_THRESHOLD})`); + } + } + + // Handle too many labels (only for non-mega PRs) const tooManyLabels = finalLabels.length > MAX_LABELS; if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { From 457689fa1d9591205528204448d8acf0242c4203 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 25 Jul 2025 21:40:42 +1200 Subject: [PATCH 14/17] [CI] Fix auto-label workflow - codeowners & listFiles (#9890) --- .github/workflows/auto-label-pr.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index f855044d31..729fae27fe 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -89,11 +89,15 @@ jobs: // Check for mega-PR early - if present, skip most automatic labeling const isMegaPR = currentLabels.includes('mega-pr'); - const { data: prFiles } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number: pr_number - }); + // Get all PR files with automatic pagination + const prFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr_number + } + ); // Calculate data from PR files const changedFiles = prFiles.map(file => file.filename); @@ -298,9 +302,10 @@ jobs: const dir = pattern.slice(0, -1); regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); } else if (pattern.includes('*')) { + // First escape all regex special chars except *, then replace * with .* const regexPattern = pattern - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - .replace(/\\*/g, '.*'); + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); regex = new RegExp(`^${regexPattern}$`); } else { regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); From 9ac10d7276d531bb88976a64beff22467f46a95f Mon Sep 17 00:00:00 2001 From: GilDev Date: Fri, 25 Jul 2025 13:33:29 +0200 Subject: [PATCH 15/17] =?UTF-8?q?[mqtt]=20Don=E2=80=99t=20log=20state=20to?= =?UTF-8?q?pic=20subscription=20for=20buttons=20(#9887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/components/mqtt/mqtt_button.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index c619a02344..b3435edf38 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -27,7 +27,7 @@ void MQTTButtonComponent::setup() { } void MQTTButtonComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Button '%s': ", this->button_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true); + LOG_MQTT_COMPONENT(false, true); } void MQTTButtonComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { From 88ccde4ba11d5a8eb50b1e3a426c1cbf58877d64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 08:14:15 -1000 Subject: [PATCH 16/17] [scheduler] Fix retry race condition on cancellation (#9788) --- esphome/core/scheduler.cpp | 20 ++++- esphome/core/scheduler.h | 25 +++++- .../fixtures/scheduler_retry_test.yaml | 78 ++++++++++++++----- .../integration/test_scheduler_retry_test.py | 14 ++-- 4 files changed, 104 insertions(+), 33 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index dd80199dc0..41027f1d3f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -65,7 +65,7 @@ static void validate_static_string(const char *name) { // Common implementation for both timeout and interval void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, - const void *name_ptr, uint32_t delay, std::function func) { + const void *name_ptr, uint32_t delay, std::function func, bool is_retry) { // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); @@ -130,6 +130,18 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type #endif /* ESPHOME_DEBUG_SCHEDULER */ LockGuard guard{this->lock_}; + + // For retries, check if there's a cancelled timeout first + if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && + (has_cancelled_timeout_in_container_(this->items_, component, name_cstr) || + has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr))) { + // Skip scheduling - the retry was cancelled +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); +#endif + return; + } + // If name is provided, do atomic cancel-and-add // Cancel existing items this->cancel_item_locked_(component, name_cstr, type); @@ -178,12 +190,14 @@ struct RetryArgs { Scheduler *scheduler; }; -static void retry_handler(const std::shared_ptr &args) { +void retry_handler(const std::shared_ptr &args) { RetryResult const retry_result = args->func(--args->retry_countdown); if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) return; // second execution of `func` happens after `initial_wait_time` - args->scheduler->set_timeout(args->component, args->name, args->current_interval, [args]() { retry_handler(args); }); + args->scheduler->set_timer_common_( + args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, + [args]() { retry_handler(args); }, true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7bf83f7877..fa189bacf7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -15,8 +15,15 @@ namespace esphome { class Component; +struct RetryArgs; + +// Forward declaration of retry_handler - needs to be non-static for friend declaration +void retry_handler(const std::shared_ptr &args); class Scheduler { + // Allow retry_handler to access protected members + friend void ::esphome::retry_handler(const std::shared_ptr &args); + public: // Public API - accepts std::string for backward compatibility void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func); @@ -147,7 +154,7 @@ class Scheduler { // Common implementation for both timeout and interval void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, - uint32_t delay, std::function func); + uint32_t delay, std::function func, bool is_retry = false); uint64_t millis_64_(uint32_t now); // Cleanup logically deleted items from the scheduler @@ -170,8 +177,8 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, - SchedulerItem::Type type) { - if (item->component != component || item->type != type || item->remove) { + SchedulerItem::Type type, bool skip_removed = true) const { + if (item->component != component || item->type != type || (skip_removed && item->remove)) { return false; } const char *item_name = item->get_name(); @@ -197,6 +204,18 @@ class Scheduler { return item->remove || (item->component != nullptr && item->component->is_failed()); } + // Template helper to check if any item in a container matches our criteria + template + bool has_cancelled_timeout_in_container_(const Container &container, Component *component, + const char *name_cstr) const { + for (const auto &item : container) { + if (item->remove && this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, false)) { + return true; + } + } + return false; + } + Mutex lock_; std::vector> items_; std::vector> to_add_; diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml index 1b6ee8e5c2..c6fcc53f8c 100644 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ b/tests/integration/fixtures/scheduler_retry_test.yaml @@ -10,7 +10,7 @@ esphome: host: api: logger: - level: VERBOSE + level: VERY_VERBOSE globals: - id: simple_retry_counter @@ -19,6 +19,9 @@ globals: - id: backoff_retry_counter type: int initial_value: '0' + - id: backoff_last_attempt_time + type: uint32_t + initial_value: '0' - id: immediate_done_counter type: int initial_value: '0' @@ -35,20 +38,55 @@ globals: type: int initial_value: '0' +# Using different component types for each test to ensure isolation sensor: - platform: template - name: Test Sensor - id: test_sensor + name: Simple Retry Test Sensor + id: simple_retry_sensor lambda: return 1.0; update_interval: never + - platform: template + name: Backoff Retry Test Sensor + id: backoff_retry_sensor + lambda: return 2.0; + update_interval: never + + - platform: template + name: Immediate Done Test Sensor + id: immediate_done_sensor + lambda: return 3.0; + update_interval: never + +binary_sensor: + - platform: template + name: Cancel Retry Test Binary Sensor + id: cancel_retry_binary_sensor + lambda: return false; + + - platform: template + name: Empty Name Test Binary Sensor + id: empty_name_binary_sensor + lambda: return true; + +switch: + - platform: template + name: Script Retry Test Switch + id: script_retry_switch + optimistic: true + + - platform: template + name: Multiple Same Name Test Switch + id: multiple_same_name_switch + optimistic: true + script: - id: run_all_tests then: # Test 1: Simple retry - logger.log: "=== Test 1: Simple retry ===" - lambda: |- - auto *component = id(test_sensor); + auto *component = id(simple_retry_sensor); App.scheduler.set_retry(component, "simple_retry", 50, 3, [](uint8_t retry_countdown) { id(simple_retry_counter)++; @@ -65,19 +103,19 @@ script: # Test 2: Backoff retry - logger.log: "=== Test 2: Retry with backoff ===" - lambda: |- - auto *component = id(test_sensor); - static uint32_t backoff_start_time = 0; - static uint32_t last_attempt_time = 0; - - backoff_start_time = millis(); - last_attempt_time = backoff_start_time; + auto *component = id(backoff_retry_sensor); App.scheduler.set_retry(component, "backoff_retry", 50, 4, [](uint8_t retry_countdown) { id(backoff_retry_counter)++; uint32_t now = millis(); - uint32_t interval = now - last_attempt_time; - last_attempt_time = now; + uint32_t interval = 0; + + // Only calculate interval after first attempt + if (id(backoff_retry_counter) > 1) { + interval = now - id(backoff_last_attempt_time); + } + id(backoff_last_attempt_time) = now; ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)", id(backoff_retry_counter), retry_countdown, interval); @@ -100,7 +138,7 @@ script: # Test 3: Immediate done - logger.log: "=== Test 3: Immediate done ===" - lambda: |- - auto *component = id(test_sensor); + auto *component = id(immediate_done_sensor); App.scheduler.set_retry(component, "immediate_done", 50, 5, [](uint8_t retry_countdown) { id(immediate_done_counter)++; @@ -111,8 +149,8 @@ script: # Test 4: Cancel retry - logger.log: "=== Test 4: Cancel retry ===" - lambda: |- - auto *component = id(test_sensor); - App.scheduler.set_retry(component, "cancel_test", 25, 10, + auto *component = id(cancel_retry_binary_sensor); + App.scheduler.set_retry(component, "cancel_test", 30, 10, [](uint8_t retry_countdown) { id(cancel_retry_counter)++; ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter)); @@ -121,7 +159,7 @@ script: // Cancel it after 100ms App.scheduler.set_timeout(component, "cancel_timer", 100, []() { - bool cancelled = App.scheduler.cancel_retry(id(test_sensor), "cancel_test"); + bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test"); ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false"); ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter)); }); @@ -129,7 +167,7 @@ script: # Test 5: Empty name retry - logger.log: "=== Test 5: Empty name retry ===" - lambda: |- - auto *component = id(test_sensor); + auto *component = id(empty_name_binary_sensor); App.scheduler.set_retry(component, "", 100, 5, [](uint8_t retry_countdown) { id(empty_name_retry_counter)++; @@ -139,7 +177,7 @@ script: // Try to cancel after 150ms App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() { - bool cancelled = App.scheduler.cancel_retry(id(test_sensor), ""); + bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), ""); ESP_LOGI("test", "Empty name retry cancel result: %s", cancelled ? "true" : "false"); ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter)); @@ -169,7 +207,7 @@ script: # Test 7: Multiple same name - logger.log: "=== Test 7: Multiple retries with same name ===" - lambda: |- - auto *component = id(test_sensor); + auto *component = id(multiple_same_name_switch); // Set first retry App.scheduler.set_retry(component, "duplicate_retry", 100, 5, @@ -200,7 +238,7 @@ script: ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter)); ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter)); ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter)); - ESP_LOGI("test", "Cancel retry counter: %d (expected ~3-4)", id(cancel_retry_counter)); + ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter)); ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter)); ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter)); ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter)); diff --git a/tests/integration/test_scheduler_retry_test.py b/tests/integration/test_scheduler_retry_test.py index 0c4d573c1b..1a469fcff1 100644 --- a/tests/integration/test_scheduler_retry_test.py +++ b/tests/integration/test_scheduler_retry_test.py @@ -148,16 +148,16 @@ async def test_scheduler_retry_test( f"Expected at least 2 intervals, got {len(backoff_intervals)}" ) if len(backoff_intervals) >= 3: - # First interval should be ~50ms - assert 30 <= backoff_intervals[0] <= 70, ( + # First interval should be ~50ms (very wide tolerance for heavy system load) + assert 20 <= backoff_intervals[0] <= 150, ( f"First interval {backoff_intervals[0]}ms not ~50ms" ) # Second interval should be ~100ms (50ms * 2.0) - assert 80 <= backoff_intervals[1] <= 120, ( + assert 50 <= backoff_intervals[1] <= 250, ( f"Second interval {backoff_intervals[1]}ms not ~100ms" ) # Third interval should be ~200ms (100ms * 2.0) - assert 180 <= backoff_intervals[2] <= 220, ( + assert 100 <= backoff_intervals[2] <= 500, ( f"Third interval {backoff_intervals[2]}ms not ~200ms" ) @@ -175,7 +175,7 @@ async def test_scheduler_retry_test( # Wait for cancel retry test try: - await asyncio.wait_for(cancel_retry_done.wait(), timeout=2.0) + await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0) except TimeoutError: pytest.fail( f"Cancel retry test did not complete. Count: {cancel_retry_count}" @@ -195,8 +195,8 @@ async def test_scheduler_retry_test( ) # Empty name retry should run at least once before being cancelled - assert 1 <= empty_name_retry_count <= 2, ( - f"Expected 1-2 empty name retry attempts, got {empty_name_retry_count}" + assert 1 <= empty_name_retry_count <= 3, ( + f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}" ) assert empty_cancel_result is True, ( "Empty name retry cancel should have succeeded" From f808c38f10f49b51d15e0e9010adf263a24bf9b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jul 2025 08:15:54 -1000 Subject: [PATCH 17/17] [ruff] Enable PERF rules and fix all violations (#9874) --- esphome/__main__.py | 6 +- esphome/components/binary_sensor/__init__.py | 23 +++--- .../components/dfrobot_sen0395/__init__.py | 3 +- esphome/components/esp32/__init__.py | 2 +- .../components/esp32_ble_tracker/__init__.py | 4 +- esphome/components/esphome/ota/__init__.py | 3 +- esphome/components/lcd_gpio/display.py | 4 +- esphome/components/light/effects.py | 70 +++++++++---------- .../components/pipsolar/sensor/__init__.py | 2 +- esphome/core/config.py | 2 +- esphome/dashboard/web_server.py | 2 +- pyproject.toml | 13 ++-- script/clang-format | 7 +- script/clang-tidy | 5 +- script/helpers_zephyr.py | 16 +++-- tests/integration/test_duplicate_entities.py | 3 +- .../integration/test_host_mode_fan_preset.py | 4 +- tests/unit_tests/test_substitutions.py | 17 ++--- 18 files changed, 90 insertions(+), 96 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index cf0fed2d67..c9f5037890 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -89,9 +89,9 @@ def choose_prompt(options, purpose: str = None): def choose_upload_log_host( default, check_default, show_ota, show_mqtt, show_api, purpose: str = None ): - options = [] - for port in get_serial_ports(): - options.append((f"{port.path} ({port.description})", port.path)) + options = [ + (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() + ] if default == "SERIAL": return choose_prompt(options, purpose=purpose) if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index c97de6d5e5..e3931e3946 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -266,8 +266,10 @@ async def delayed_off_filter_to_code(config, filter_id): async def autorepeat_filter_to_code(config, filter_id): timings = [] if len(config) > 0: - for conf in config: - timings.append((conf[CONF_DELAY], conf[CONF_TIME_OFF], conf[CONF_TIME_ON])) + timings.extend( + (conf[CONF_DELAY], conf[CONF_TIME_OFF], conf[CONF_TIME_ON]) + for conf in config + ) else: timings.append( ( @@ -573,16 +575,15 @@ async def setup_binary_sensor_core_(var, config): await automation.build_automation(trigger, [], conf) for conf in config.get(CONF_ON_MULTI_CLICK, []): - timings = [] - for tim in conf[CONF_TIMING]: - timings.append( - cg.StructInitializer( - MultiClickTriggerEvent, - ("state", tim[CONF_STATE]), - ("min_length", tim[CONF_MIN_LENGTH]), - ("max_length", tim.get(CONF_MAX_LENGTH, 4294967294)), - ) + timings = [ + cg.StructInitializer( + MultiClickTriggerEvent, + ("state", tim[CONF_STATE]), + ("min_length", tim[CONF_MIN_LENGTH]), + ("max_length", tim.get(CONF_MAX_LENGTH, 4294967294)), ) + for tim in conf[CONF_TIMING] + ] trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, timings) if CONF_INVALID_COOLDOWN in conf: cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 8c9438dccb..d54b147036 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -74,8 +74,7 @@ def range_segment_list(input): if isinstance(input, list): for list_item in input: if isinstance(list_item, list): - for item in list_item: - flat_list.append(item) + flat_list.extend(list_item) else: flat_list.append(list_item) else: diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f0a5517185..2b4c4ff043 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -982,7 +982,7 @@ def copy_files(): __version__, ) - for _, file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].items(): + for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): if file[KEY_PATH].startswith("http"): import requests diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 046f3f679f..9daa6ee34e 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -310,9 +310,7 @@ async def to_code(config): for conf in config.get(CONF_ON_BLE_ADVERTISE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: - addr_list = [] - for it in conf[CONF_MAC_ADDRESS]: - addr_list.append(it.as_hex) + addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 901657ec82..9facdc3bc6 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -73,8 +73,7 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) - for port_conf in merged_ota_esphome_configs_by_port.values(): - new_ota_conf.append(port_conf) + new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) full_conf[CONF_OTA] = new_ota_conf fv.full_config.set(full_conf) diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index caa73194c9..0a77daf336 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = lcd_base.LCD_SCHEMA.extend( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await lcd_base.setup_lcd_display(var, config) - pins_ = [] - for conf in config[CONF_DATA_PINS]: - pins_.append(await cg.gpio_pin_expression(conf)) + pins_ = [await cg.gpio_pin_expression(conf) for conf in config[CONF_DATA_PINS]] cg.add(var.set_data_pins(*pins_)) enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) cg.add(var.set_enable_pin(enable)) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 6f878ff5f1..f5749a17ab 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -291,31 +291,30 @@ async def random_effect_to_code(config, effect_id): ) async def strobe_effect_to_code(config, effect_id): var = cg.new_Pvariable(effect_id, config[CONF_NAME]) - colors = [] - for color in config.get(CONF_COLORS, []): - colors.append( - cg.StructInitializer( - StrobeLightEffectColor, - ( - "color", - LightColorValues( - color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - color[CONF_STATE], - color[CONF_BRIGHTNESS], - color[CONF_COLOR_BRIGHTNESS], - color[CONF_RED], - color[CONF_GREEN], - color[CONF_BLUE], - color[CONF_WHITE], - color.get(CONF_COLOR_TEMPERATURE, 0.0), - color[CONF_COLD_WHITE], - color[CONF_WARM_WHITE], - ), + colors = [ + cg.StructInitializer( + StrobeLightEffectColor, + ( + "color", + LightColorValues( + color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), + color[CONF_STATE], + color[CONF_BRIGHTNESS], + color[CONF_COLOR_BRIGHTNESS], + color[CONF_RED], + color[CONF_GREEN], + color[CONF_BLUE], + color[CONF_WHITE], + color.get(CONF_COLOR_TEMPERATURE, 0.0), + color[CONF_COLD_WHITE], + color[CONF_WARM_WHITE], ), - ("duration", color[CONF_DURATION]), - ("transition_length", color[CONF_TRANSITION_LENGTH]), - ) + ), + ("duration", color[CONF_DURATION]), + ("transition_length", color[CONF_TRANSITION_LENGTH]), ) + for color in config.get(CONF_COLORS, []) + ] cg.add(var.set_colors(colors)) return var @@ -404,20 +403,19 @@ async def addressable_color_wipe_effect_to_code(config, effect_id): var = cg.new_Pvariable(effect_id, config[CONF_NAME]) cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL])) cg.add(var.set_reverse(config[CONF_REVERSE])) - colors = [] - for color in config.get(CONF_COLORS, []): - colors.append( - cg.StructInitializer( - AddressableColorWipeEffectColor, - ("r", int(round(color[CONF_RED] * 255))), - ("g", int(round(color[CONF_GREEN] * 255))), - ("b", int(round(color[CONF_BLUE] * 255))), - ("w", int(round(color[CONF_WHITE] * 255))), - ("random", color[CONF_RANDOM]), - ("num_leds", color[CONF_NUM_LEDS]), - ("gradient", color[CONF_GRADIENT]), - ) + colors = [ + cg.StructInitializer( + AddressableColorWipeEffectColor, + ("r", int(round(color[CONF_RED] * 255))), + ("g", int(round(color[CONF_GREEN] * 255))), + ("b", int(round(color[CONF_BLUE] * 255))), + ("w", int(round(color[CONF_WHITE] * 255))), + ("random", color[CONF_RANDOM]), + ("num_leds", color[CONF_NUM_LEDS]), + ("gradient", color[CONF_GRADIENT]), ) + for color in config.get(CONF_COLORS, []) + ] cg.add(var.set_colors(colors)) return var diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 0d00ba0083..929865b480 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -273,7 +273,7 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( async def to_code(config): paren = await cg.get_variable(config[CONF_PIPSOLAR_ID]) - for type, _ in TYPES.items(): + for type in TYPES: if type in config: conf = config[type] sens = await sensor.new_sensor(conf) diff --git a/esphome/core/config.py b/esphome/core/config.py index f73369f28f..6d93117164 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -317,7 +317,7 @@ def preload_core_config(config, result) -> str: target_platforms = [] - for domain, _ in config.items(): + for domain in config: if domain.startswith("."): continue if _is_target_platform(domain): diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 480285b6c1..286dc9e1d7 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -144,7 +144,7 @@ def websocket_class(cls): if not hasattr(cls, "_message_handlers"): cls._message_handlers = {} - for _, method in cls.__dict__.items(): + for method in cls.__dict__.values(): if hasattr(method, "_message_handler"): cls._message_handlers[method._message_handler] = method diff --git a/pyproject.toml b/pyproject.toml index 971b9fbf74..742cd6e83d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,12 +111,13 @@ exclude = ['generated'] [tool.ruff.lint] select = [ - "E", # pycodestyle - "F", # pyflakes/autoflake - "I", # isort - "PL", # pylint - "SIM", # flake8-simplify - "UP", # pyupgrade + "E", # pycodestyle + "F", # pyflakes/autoflake + "I", # isort + "PERF", # performance + "PL", # pylint + "SIM", # flake8-simplify + "UP", # pyupgrade ] ignore = [ diff --git a/script/clang-format b/script/clang-format index b1e84a56b7..d62a5b59c7 100755 --- a/script/clang-format +++ b/script/clang-format @@ -66,9 +66,10 @@ def main(): ) args = parser.parse_args() - files = [] - for path in git_ls_files(["*.cpp", "*.h", "*.tcc"]): - files.append(os.path.relpath(path, os.getcwd())) + cwd = os.getcwd() + files = [ + os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp", "*.h", "*.tcc"]) + ] if args.files: # Match against files specified on command-line diff --git a/script/clang-tidy b/script/clang-tidy index 187acd02ad..9576b8da8b 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -219,9 +219,8 @@ def main(): ) args = parser.parse_args() - files = [] - for path in git_ls_files(["*.cpp"]): - files.append(os.path.relpath(path, os.getcwd())) + cwd = os.getcwd() + files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])] # Print initial file count if it's large if len(files) > 50: diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index c3ba149005..305ca00c0c 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -41,11 +41,12 @@ CONFIG_NEWLIB_LIBC=y return include_paths def extract_defines(command): - defines = [] define_pattern = re.compile(r"-D\s*([^\s]+)") - for match in define_pattern.findall(command): - if match not in ("_ASMLANGUAGE"): - defines.append(match) + defines = [ + match + for match in define_pattern.findall(command) + if match not in ("_ASMLANGUAGE") + ] return defines def find_cxx_path(commands): @@ -78,13 +79,14 @@ CONFIG_NEWLIB_LIBC=y return include_paths def extract_cxx_flags(command): - flags = [] # Extracts CXXFLAGS from the command string, excluding includes and defines. flag_pattern = re.compile( r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" ) - for match in flag_pattern.findall(command): - flags.append(match.replace("-imacros ", "-imacros")) + flags = [ + match.replace("-imacros ", "-imacros") + for match in flag_pattern.findall(command) + ] return flags def transform_to_idedata_format(compile_commands): diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index c738bb3fe0..9b2670c4ae 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -38,8 +38,7 @@ async def test_duplicate_entities_on_different_devices( # Get entity list entities = await client.list_entities_services() all_entities: list[EntityInfo] = [] - for entity_list in entities[0]: - all_entities.append(entity_list) + all_entities.extend(entities[0]) # Group entities by type for easier testing sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"] diff --git a/tests/integration/test_host_mode_fan_preset.py b/tests/integration/test_host_mode_fan_preset.py index d18b9f08ad..d44fdc0a3b 100644 --- a/tests/integration/test_host_mode_fan_preset.py +++ b/tests/integration/test_host_mode_fan_preset.py @@ -23,9 +23,7 @@ async def test_host_mode_fan_preset( entities = await client.list_entities_services() fans: list[FanInfo] = [] for entity_list in entities: - for entity in entity_list: - if isinstance(entity, FanInfo): - fans.append(entity) + fans.extend(entity for entity in entity_list if isinstance(entity, FanInfo)) # Create a map of fan names to entity info fan_map = {fan.name: fan for fan in fans} diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 3208923116..b65fecb26e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -31,10 +31,8 @@ def dict_diff(a, b, path=""): if isinstance(a, dict) and isinstance(b, dict): a_keys = set(a) b_keys = set(b) - for key in a_keys - b_keys: - diffs.append(f"{path}/{key} only in actual") - for key in b_keys - a_keys: - diffs.append(f"{path}/{key} only in expected") + diffs.extend(f"{path}/{key} only in actual" for key in a_keys - b_keys) + diffs.extend(f"{path}/{key} only in expected" for key in b_keys - a_keys) for key in a_keys & b_keys: diffs.extend(dict_diff(a[key], b[key], f"{path}/{key}")) elif isinstance(a, list) and isinstance(b, list): @@ -42,11 +40,14 @@ def dict_diff(a, b, path=""): for i in range(min_len): diffs.extend(dict_diff(a[i], b[i], f"{path}[{i}]")) if len(a) > len(b): - for i in range(min_len, len(a)): - diffs.append(f"{path}[{i}] only in actual: {a[i]!r}") + diffs.extend( + f"{path}[{i}] only in actual: {a[i]!r}" for i in range(min_len, len(a)) + ) elif len(b) > len(a): - for i in range(min_len, len(b)): - diffs.append(f"{path}[{i}] only in expected: {b[i]!r}") + diffs.extend( + f"{path}[{i}] only in expected: {b[i]!r}" + for i in range(min_len, len(b)) + ) elif a != b: diffs.append(f"\t{path}: actual={a!r} expected={b!r}") return diffs