diff --git a/Doxyfile b/Doxyfile index a601fcc8eb..ae1e519030 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.7.2 +PROJECT_NUMBER = 2025.7.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 8ee463a59a..330dc4dd9c 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import esp32, i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET +from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] @@ -56,7 +56,15 @@ CONFIG_SCHEMA = cv.All( ): cv.positive_time_period_minutes, } ).extend(i2c.i2c_device_schema(0x76)), - cv.only_with_arduino, + cv.only_with_framework( + frameworks=Framework.ARDUINO, + suggestions={ + Framework.ESP_IDF: ( + "bme68x_bsec2_i2c", + "sensor/bme68x_bsec2", + ) + }, + ), cv.Any( cv.only_on_esp8266, cv.All( diff --git a/esphome/components/esp32_touch/esp32_touch_v1.cpp b/esphome/components/esp32_touch/esp32_touch_v1.cpp index c3d43c6bbf..629dc8e793 100644 --- a/esphome/components/esp32_touch/esp32_touch_v1.cpp +++ b/esphome/components/esp32_touch/esp32_touch_v1.cpp @@ -16,6 +16,8 @@ namespace esp32_touch { static const char *const TAG = "esp32_touch"; +static const uint32_t SETUP_MODE_THRESHOLD = 0xFFFF; + void ESP32TouchComponent::setup() { // Create queue for touch events // Queue size calculation: children * 4 allows for burst scenarios where ISR @@ -44,7 +46,11 @@ void ESP32TouchComponent::setup() { // Configure each touch pad for (auto *child : this->children_) { - touch_pad_config(child->get_touch_pad(), child->get_threshold()); + if (this->setup_mode_) { + touch_pad_config(child->get_touch_pad(), SETUP_MODE_THRESHOLD); + } else { + touch_pad_config(child->get_touch_pad(), child->get_threshold()); + } } // Register ISR handler @@ -114,8 +120,8 @@ void ESP32TouchComponent::loop() { child->publish_state(new_state); // Original ESP32: ISR only fires when touched, release is detected by timeout // Note: ESP32 v1 uses inverted logic - touched when value < threshold - ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 " < threshold: %" PRIu32 ")", - child->get_name().c_str(), event.value, child->get_threshold()); + ESP_LOGV(TAG, "Touch Pad '%s' state: %s (value: %" PRIu32 " < threshold: %" PRIu32 ")", + child->get_name().c_str(), ONOFF(new_state), event.value, child->get_threshold()); } break; // Exit inner loop after processing matching pad } @@ -188,11 +194,6 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { // as any pad remains touched. This allows us to detect both new touches and // continued touches, but releases must be detected by timeout in the main loop. - // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! - // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE - // Therefore: touched = (value < threshold) - // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) - // Process all configured pads to check their current state // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt, // so we must scan all configured pads to find which ones were touched @@ -211,11 +212,16 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { } // Skip pads that aren’t in the trigger mask - bool is_touched = (mask >> pad) & 1; - if (!is_touched) { + if (((mask >> pad) & 1) == 0) { continue; } + // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! + // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE + // Therefore: touched = (value < threshold) + // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) + bool is_touched = value < child->get_threshold(); + // Always send the current state - the main loop will filter for changes // We send both touched and untouched states because the ISR doesn't // track previous state (to keep ISR fast and simple) diff --git a/esphome/components/fastled_clockless/light.py b/esphome/components/fastled_clockless/light.py index 49a6d390be..aa2172bf88 100644 --- a/esphome/components/fastled_clockless/light.py +++ b/esphome/components/fastled_clockless/light.py @@ -2,7 +2,13 @@ from esphome import pins import esphome.codegen as cg from esphome.components import fastled_base import esphome.config_validation as cv -from esphome.const import CONF_CHIPSET, CONF_NUM_LEDS, CONF_PIN, CONF_RGB_ORDER +from esphome.const import ( + CONF_CHIPSET, + CONF_NUM_LEDS, + CONF_PIN, + CONF_RGB_ORDER, + Framework, +) AUTO_LOAD = ["fastled_base"] @@ -48,13 +54,22 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, } ), - _validate, + cv.only_with_framework( + frameworks=Framework.ARDUINO, + suggestions={ + Framework.ESP_IDF: ( + "esp32_rmt_led_strip", + "light/esp32_rmt_led_strip", + ) + }, + ), cv.require_framework_version( esp8266_arduino=cv.Version(2, 7, 4), esp32_arduino=cv.Version(99, 0, 0), max_version=True, extra_message="Please see note on documentation for FastLED", ), + _validate, ) diff --git a/esphome/components/fastled_spi/light.py b/esphome/components/fastled_spi/light.py index ac30721eb4..81d8c3b32a 100644 --- a/esphome/components/fastled_spi/light.py +++ b/esphome/components/fastled_spi/light.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_DATA_RATE, CONF_NUM_LEDS, CONF_RGB_ORDER, + Framework, ) AUTO_LOAD = ["fastled_base"] @@ -33,6 +34,15 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DATA_RATE): cv.frequency, } ), + cv.only_with_framework( + frameworks=Framework.ARDUINO, + suggestions={ + Framework.ESP_IDF: ( + "spi_led_strip", + "light/spi_led_strip", + ) + }, + ), cv.require_framework_version( esp8266_arduino=cv.Version(2, 7, 4), esp32_arduino=cv.Version(99, 0, 0), diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 59f54520fa..8372bc7e08 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -4,7 +4,13 @@ from esphome import pins import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME, CONF_NUMBER, CONF_PIN +from esphome.const import ( + CONF_ALLOW_OTHER_USES, + CONF_ID, + CONF_NAME, + CONF_NUMBER, + CONF_PIN, +) from esphome.core import CORE from .. import gpio_ns @@ -76,6 +82,18 @@ async def to_code(config): ) use_interrupt = False + # Check if pin is shared with other components (allow_other_uses) + # When a pin is shared, interrupts can interfere with other components + # (e.g., duty_cycle sensor) that need to monitor the pin's state changes + if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False): + _LOGGER.info( + "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. " + "The sensor will use polling mode for compatibility with other pin uses.", + config.get(CONF_NAME, config[CONF_ID]), + config[CONF_PIN][CONF_NUMBER], + ) + use_interrupt = False + cg.add(var.set_use_interrupt(use_interrupt)) if use_interrupt: cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 3cd1bfd357..c63790e60b 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_PIN, CONF_TYPE, CONF_VARIANT, + Framework, ) from esphome.core import CORE @@ -162,7 +163,15 @@ def _validate_method(value): CONFIG_SCHEMA = cv.All( - cv.only_with_arduino, + cv.only_with_framework( + frameworks=Framework.ARDUINO, + suggestions={ + Framework.ESP_IDF: ( + "esp32_rmt_led_strip", + "light/esp32_rmt_led_strip", + ) + }, + ), cv.require_framework_version( esp8266_arduino=cv.Version(2, 4, 0), esp32_arduino=cv.Version(0, 0, 0), diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index e55bff58fe..5ad18f6311 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -48,6 +48,9 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y)) + return; + SDL_Rect rect{x, y, 1, 1}; auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB)); SDL_UpdateTexture(this->texture_, &rect, &data, 2); diff --git a/esphome/components/tuya/fan/__init__.py b/esphome/components/tuya/fan/__init__.py index c732bdaf31..de95888b6b 100644 --- a/esphome/components/tuya/fan/__init__.py +++ b/esphome/components/tuya/fan/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT +from esphome.const import CONF_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT from .. import CONF_TUYA_ID, Tuya, tuya_ns @@ -14,9 +14,9 @@ CONF_DIRECTION_DATAPOINT = "direction_datapoint" TuyaFan = tuya_ns.class_("TuyaFan", cg.Component, fan.Fan) CONFIG_SCHEMA = cv.All( - fan.FAN_SCHEMA.extend( + fan.fan_schema(TuyaFan) + .extend( { - cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(TuyaFan), cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya), cv.Optional(CONF_OSCILLATION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t, @@ -24,7 +24,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256), } - ).extend(cv.COMPONENT_SCHEMA), + ) + .extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT), ) @@ -32,7 +33,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): parent = await cg.get_variable(config[CONF_TUYA_ID]) - var = cg.new_Pvariable(config[CONF_OUTPUT_ID], parent, config[CONF_SPEED_COUNT]) + var = cg.new_Pvariable(config[CONF_ID], parent, config[CONF_SPEED_COUNT]) await cg.register_component(var, config) await fan.register_fan(var, config) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 966c1c1024..7211f707e9 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -76,7 +76,7 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { percentage = (this->ota_read_length_ * 100.0f) / request->contentLength(); ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage); } else { - ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_); + ESP_LOGD(TAG, "OTA in progress: %" PRIu32 " bytes read", this->ota_read_length_); } #ifdef USE_OTA_STATE_CALLBACK // Report progress - use call_deferred since we're in web server task @@ -171,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin // Finalize if (final) { - ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%u, contentLength=%zu", index, len, + ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%" PRIu32 ", contentLength=%zu", index, len, this->ota_read_length_, request->contentLength()); // For Arduino framework, the Update library tracks expected size from firmware header diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 09b132a458..1c524cab6b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -73,6 +73,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, VALID_SUBSTITUTIONS_CHARACTERS, + Framework, __version__ as ESPHOME_VERSION, ) from esphome.core import ( @@ -282,6 +283,38 @@ class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + extra: str = "" + + def __str__(self): + return f"{self.major}.{self.minor}.{self.patch}" + + @classmethod + def parse(cls, value: str) -> Version: + match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value) + if match is None: + raise ValueError(f"Not a valid version number {value}") + major = int(match[1]) + minor = int(match[2]) + patch = int(match[3]) + extra = match[4] or "" + return Version(major=major, minor=minor, patch=patch, extra=extra) + + @property + def is_beta(self) -> bool: + """Check if this version is a beta version.""" + return self.extra.startswith("b") + + @property + def is_dev(self) -> bool: + """Check if this version is a development version.""" + return self.extra.startswith("dev") + + def check_not_templatable(value): if isinstance(value, Lambda): raise Invalid("This option is not templatable!") @@ -619,16 +652,35 @@ def only_on(platforms): return validator_ -def only_with_framework(frameworks): +def only_with_framework( + frameworks: Framework | str | list[Framework | str], suggestions=None +): """Validate that this option can only be specified on the given frameworks.""" if not isinstance(frameworks, list): frameworks = [frameworks] + frameworks = [Framework(framework) for framework in frameworks] + + if suggestions is None: + suggestions = {} + + version = Version.parse(ESPHOME_VERSION) + if version.is_beta: + docs_format = "https://beta.esphome.io/components/{path}" + elif version.is_dev: + docs_format = "https://next.esphome.io/components/{path}" + else: + docs_format = "https://esphome.io/components/{path}" + def validator_(obj): if CORE.target_framework not in frameworks: - raise Invalid( - f"This feature is only available with frameworks {frameworks}" - ) + err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}" + if suggestion := suggestions.get(CORE.target_framework, None): + (component, docs_path) = suggestion + err_str += f"\nPlease use '{component}'" + if docs_path: + err_str += f": {docs_format.format(path=docs_path)}" + raise Invalid(err_str) return obj return validator_ @@ -637,8 +689,8 @@ def only_with_framework(frameworks): only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_rp2040 = only_on(PLATFORM_RP2040) -only_with_arduino = only_with_framework("arduino") -only_with_esp_idf = only_with_framework("esp-idf") +only_with_arduino = only_with_framework(Framework.ARDUINO) +only_with_esp_idf = only_with_framework(Framework.ESP_IDF) # Adapted from: @@ -1965,26 +2017,6 @@ def source_refresh(value: str): return positive_time_period_seconds(value) -@dataclass(frozen=True, order=True) -class Version: - major: int - minor: int - patch: int - - def __str__(self): - return f"{self.major}.{self.minor}.{self.patch}" - - @classmethod - def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)-?\w*$", value) - if match is None: - raise ValueError(f"Not a valid version number {value}") - major = int(match[1]) - minor = int(match[2]) - patch = int(match[3]) - return Version(major=major, minor=minor, patch=patch) - - def version_number(value): value = string_strict(value) try: diff --git a/esphome/const.py b/esphome/const.py index 93b509f72a..476059af62 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.7.2" +__version__ = "2025.7.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index e19acd3ba6..68bcc99d31 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -68,8 +68,11 @@ void Application::setup() { do { uint8_t new_app_state = STATUS_LED_WARNING; - this->scheduler.call(); - this->feed_wdt(); + uint32_t now = millis(); + + // Process pending loop enables to handle GPIO interrupts during setup + this->before_loop_tasks_(now); + for (uint32_t j = 0; j <= i; j++) { // Update loop_component_start_time_ right before calling each component this->loop_component_start_time_ = millis(); @@ -78,6 +81,8 @@ void Application::setup() { this->app_state_ |= new_app_state; this->feed_wdt(); } + + this->after_loop_tasks_(); this->app_state_ = new_app_state; yield(); } while (!component->can_proceed()); @@ -94,30 +99,10 @@ void Application::setup() { void Application::loop() { uint8_t new_app_state = 0; - this->scheduler.call(); - // Get the initial loop time at the start uint32_t last_op_end_time = millis(); - // Feed WDT with time - this->feed_wdt(last_op_end_time); - - // Process any pending enable_loop requests from ISRs - // This must be done before marking in_loop_ = true to avoid race conditions - if (this->has_pending_enable_loop_requests_) { - // Clear flag BEFORE processing to avoid race condition - // If ISR sets it during processing, we'll catch it next loop iteration - // This is safe because: - // 1. Each component has its own pending_enable_loop_ flag that we check - // 2. If we can't process a component (wrong state), enable_pending_loops_() - // will set this flag back to true - // 3. Any new ISR requests during processing will set the flag again - this->has_pending_enable_loop_requests_ = false; - this->enable_pending_loops_(); - } - - // Mark that we're in the loop for safe reentrant modifications - this->in_loop_ = true; + this->before_loop_tasks_(last_op_end_time); for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; this->current_loop_index_++) { @@ -138,7 +123,7 @@ void Application::loop() { this->feed_wdt(last_op_end_time); } - this->in_loop_ = false; + this->after_loop_tasks_(); this->app_state_ = new_app_state; // Use the last component's end time instead of calling millis() again @@ -400,6 +385,36 @@ void Application::enable_pending_loops_() { } } +void Application::before_loop_tasks_(uint32_t loop_start_time) { + // Process scheduled tasks + this->scheduler.call(); + + // Feed the watchdog timer + this->feed_wdt(loop_start_time); + + // Process any pending enable_loop requests from ISRs + // This must be done before marking in_loop_ = true to avoid race conditions + if (this->has_pending_enable_loop_requests_) { + // Clear flag BEFORE processing to avoid race condition + // If ISR sets it during processing, we'll catch it next loop iteration + // This is safe because: + // 1. Each component has its own pending_enable_loop_ flag that we check + // 2. If we can't process a component (wrong state), enable_pending_loops_() + // will set this flag back to true + // 3. Any new ISR requests during processing will set the flag again + this->has_pending_enable_loop_requests_ = false; + this->enable_pending_loops_(); + } + + // Mark that we're in the loop for safe reentrant modifications + this->in_loop_ = true; +} + +void Application::after_loop_tasks_() { + // Clear the in_loop_ flag to indicate we're done processing components + this->in_loop_ = false; +} + #ifdef USE_SOCKET_SELECT_SUPPORT bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop diff --git a/esphome/core/application.h b/esphome/core/application.h index f2b5cb5c89..f675881a27 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -504,6 +504,8 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); + void before_loop_tasks_(uint32_t loop_start_time); + void after_loop_tasks_(); void feed_wdt_arch_();