Merge pull request #9691 from esphome/bump-2025.7.2

2025.7.2
This commit is contained in:
Jesse Hills 2025-07-19 12:04:51 +12:00 committed by GitHub
commit 6fe4ffa0cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 447 additions and 62 deletions

View File

@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2025.7.1 PROJECT_NUMBER = 2025.7.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description # 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 # for a project that appears at the top of each page and should give viewer a

View File

@ -16,6 +16,9 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); } template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
// Overloads for string types - needed because std::to_string doesn't support them // Overloads for string types - needed because std::to_string doesn't support them
static std::string value_to_string(char *val) {
return val ? std::string(val) : std::string();
} // For lambdas returning char* (e.g., itoa)
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str() static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
static std::string value_to_string(const std::string &val) { return val; } static std::string value_to_string(const std::string &val) { return val; }
static std::string value_to_string(std::string &&val) { return std::move(val); } static std::string value_to_string(std::string &&val) { return std::move(val); }

View File

@ -1,3 +1,5 @@
import logging
from esphome import automation, pins from esphome import automation, pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import i2c
@ -8,6 +10,7 @@ from esphome.const import (
CONF_CONTRAST, CONF_CONTRAST,
CONF_DATA_PINS, CONF_DATA_PINS,
CONF_FREQUENCY, CONF_FREQUENCY,
CONF_I2C,
CONF_I2C_ID, CONF_I2C_ID,
CONF_ID, CONF_ID,
CONF_PIN, CONF_PIN,
@ -20,6 +23,9 @@ from esphome.const import (
) )
from esphome.core import CORE from esphome.core import CORE
from esphome.core.entity_helpers import setup_entity from esphome.core.entity_helpers import setup_entity
import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["esp32"] DEPENDENCIES = ["esp32"]
@ -250,6 +256,22 @@ CONFIG_SCHEMA = cv.All(
cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID), cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID),
) )
def _final_validate(config):
if CONF_I2C_PINS not in config:
return
fconf = fv.full_config.get()
if fconf.get(CONF_I2C):
raise cv.Invalid(
"The `i2c_pins:` config option is incompatible with an dedicated `i2c:` block, use `i2c_id` instead"
)
_LOGGER.warning(
"The `i2c_pins:` config option is deprecated. Use `i2c_id:` with a dedicated `i2c:` definition instead."
)
FINAL_VALIDATE_SCHEMA = _final_validate
SETTERS = { SETTERS = {
# pin assignment # pin assignment
CONF_DATA_PINS: "set_data_pins", CONF_DATA_PINS: "set_data_pins",

View File

@ -29,7 +29,21 @@ CONFIG_SCHEMA = (
.extend( .extend(
{ {
cv.Required(CONF_PIN): pins.gpio_input_pin_schema, cv.Required(CONF_PIN): pins.gpio_input_pin_schema,
cv.Optional(CONF_USE_INTERRUPT, default=True): cv.boolean, # Interrupts are disabled by default for bk72xx, ln882x, and rtl87xx platforms
# due to hardware limitations or lack of reliable interrupt support. This ensures
# stable operation on these platforms. Future maintainers should verify platform
# capabilities before changing this default behavior.
cv.SplitDefault(
CONF_USE_INTERRUPT,
bk72xx=False,
esp32=True,
esp8266=True,
host=True,
ln882x=False,
nrf52=True,
rp2040=True,
rtl87xx=False,
): cv.boolean,
cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum( cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum(
INTERRUPT_TYPES, upper=True INTERRUPT_TYPES, upper=True
), ),

View File

@ -183,7 +183,7 @@ def validate_local_no_higher_than_global(value):
Logger = logger_ns.class_("Logger", cg.Component) Logger = logger_ns.class_("Logger", cg.Component)
LoggerMessageTrigger = logger_ns.class_( LoggerMessageTrigger = logger_ns.class_(
"LoggerMessageTrigger", "LoggerMessageTrigger",
automation.Trigger.template(cg.int_, cg.const_char_ptr, cg.const_char_ptr), automation.Trigger.template(cg.uint8, cg.const_char_ptr, cg.const_char_ptr),
) )
CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash"
@ -368,7 +368,7 @@ async def to_code(config):
await automation.build_automation( await automation.build_automation(
trigger, trigger,
[ [
(cg.int_, "level"), (cg.uint8, "level"),
(cg.const_char_ptr, "tag"), (cg.const_char_ptr, "tag"),
(cg.const_char_ptr, "message"), (cg.const_char_ptr, "message"),
], ],

View File

@ -192,7 +192,7 @@ class WidgetType:
class NumberType(WidgetType): class NumberType(WidgetType):
def get_max(self, config: dict): def get_max(self, config: dict):
return int(config[CONF_MAX_VALUE] or 100) return int(config.get(CONF_MAX_VALUE, 100))
def get_min(self, config: dict): def get_min(self, config: dict):
return int(config[CONF_MIN_VALUE] or 0) return int(config.get(CONF_MIN_VALUE, 0))

View File

@ -14,6 +14,7 @@ from esphome.const import (
CONF_VALUE, CONF_VALUE,
CONF_WIDTH, CONF_WIDTH,
) )
from esphome.cpp_generator import IntLiteral
from ..automation import action_to_code from ..automation import action_to_code
from ..defines import ( from ..defines import (
@ -188,6 +189,8 @@ class MeterType(WidgetType):
rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2
if CONF_ROTATION in scale_conf: if CONF_ROTATION in scale_conf:
rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) rotation = await lv_angle.process(scale_conf[CONF_ROTATION])
if isinstance(rotation, IntLiteral):
rotation = int(str(rotation)) // 10
with LocalVariable( with LocalVariable(
"meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var)
) as meter_var: ) as meter_var:

View File

@ -200,7 +200,7 @@ AudioPipelineState AudioPipeline::process_state() {
if ((this->read_task_handle_ != nullptr) || (this->decode_task_handle_ != nullptr)) { if ((this->read_task_handle_ != nullptr) || (this->decode_task_handle_ != nullptr)) {
this->delete_tasks_(); this->delete_tasks_();
if (this->hard_stop_) { if (this->hard_stop_) {
// Stop command was sent, so immediately end of the playback // Stop command was sent, so immediately end the playback
this->speaker_->stop(); this->speaker_->stop();
this->hard_stop_ = false; this->hard_stop_ = false;
} else { } else {
@ -210,13 +210,25 @@ AudioPipelineState AudioPipeline::process_state() {
} }
} }
this->is_playing_ = false; this->is_playing_ = false;
return AudioPipelineState::STOPPED; if (!this->speaker_->is_running()) {
return AudioPipelineState::STOPPED;
} else {
this->is_finishing_ = true;
}
} }
if (this->pause_state_) { if (this->pause_state_) {
return AudioPipelineState::PAUSED; return AudioPipelineState::PAUSED;
} }
if (this->is_finishing_) {
if (!this->speaker_->is_running()) {
this->is_finishing_ = false;
} else {
return AudioPipelineState::PLAYING;
}
}
if ((this->read_task_handle_ == nullptr) && (this->decode_task_handle_ == nullptr)) { if ((this->read_task_handle_ == nullptr) && (this->decode_task_handle_ == nullptr)) {
// No tasks are running, so the pipeline is stopped. // No tasks are running, so the pipeline is stopped.
xEventGroupClearBits(this->event_group_, EventGroupBits::PIPELINE_COMMAND_STOP); xEventGroupClearBits(this->event_group_, EventGroupBits::PIPELINE_COMMAND_STOP);

View File

@ -114,6 +114,7 @@ class AudioPipeline {
bool hard_stop_{false}; bool hard_stop_{false};
bool is_playing_{false}; bool is_playing_{false};
bool is_finishing_{false};
bool pause_state_{false}; bool pause_state_{false};
bool task_stack_in_psram_; bool task_stack_in_psram_;

View File

@ -35,6 +35,27 @@ void VoiceAssistant::setup() {
temp_ring_buffer->write((void *) data.data(), data.size()); temp_ring_buffer->write((void *) data.data(), data.size());
} }
}); });
#ifdef USE_MEDIA_PLAYER
if (this->media_player_ != nullptr) {
this->media_player_->add_on_state_callback([this]() {
switch (this->media_player_->state) {
case media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING:
if (this->media_player_response_state_ == MediaPlayerResponseState::URL_SENT) {
// State changed to announcing after receiving the url
this->media_player_response_state_ = MediaPlayerResponseState::PLAYING;
}
break;
default:
if (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING) {
// No longer announcing the TTS response
this->media_player_response_state_ = MediaPlayerResponseState::FINISHED;
}
break;
}
});
}
#endif
} }
float VoiceAssistant::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } float VoiceAssistant::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
@ -223,6 +244,13 @@ void VoiceAssistant::loop() {
msg.wake_word_phrase = this->wake_word_; msg.wake_word_phrase = this->wake_word_;
this->wake_word_ = ""; this->wake_word_ = "";
// Reset media player state tracking
#ifdef USE_MEDIA_PLAYER
if (this->media_player_ != nullptr) {
this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
}
#endif
if (this->api_client_ == nullptr || !this->api_client_->send_message(msg)) { if (this->api_client_ == nullptr || !this->api_client_->send_message(msg)) {
ESP_LOGW(TAG, "Could not request start"); ESP_LOGW(TAG, "Could not request start");
this->error_trigger_->trigger("not-connected", "Could not request start"); this->error_trigger_->trigger("not-connected", "Could not request start");
@ -314,17 +342,10 @@ void VoiceAssistant::loop() {
#endif #endif
#ifdef USE_MEDIA_PLAYER #ifdef USE_MEDIA_PLAYER
if (this->media_player_ != nullptr) { if (this->media_player_ != nullptr) {
playing = (this->media_player_->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING); playing = (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING);
if (playing && this->media_player_wait_for_announcement_start_) { if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) {
// Announcement has started playing, wait for it to finish this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
this->media_player_wait_for_announcement_start_ = false;
this->media_player_wait_for_announcement_end_ = true;
}
if (!playing && this->media_player_wait_for_announcement_end_) {
// Announcement has finished playing
this->media_player_wait_for_announcement_end_ = false;
this->cancel_timeout("playing"); this->cancel_timeout("playing");
ESP_LOGD(TAG, "Announcement finished playing"); ESP_LOGD(TAG, "Announcement finished playing");
this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
@ -555,7 +576,7 @@ void VoiceAssistant::request_stop() {
break; break;
case State::AWAITING_RESPONSE: case State::AWAITING_RESPONSE:
this->signal_stop_(); this->signal_stop_();
// Fallthrough intended to stop a streaming TTS announcement that has potentially started break;
case State::STREAMING_RESPONSE: case State::STREAMING_RESPONSE:
#ifdef USE_MEDIA_PLAYER #ifdef USE_MEDIA_PLAYER
// Stop any ongoing media player announcement // Stop any ongoing media player announcement
@ -565,6 +586,10 @@ void VoiceAssistant::request_stop() {
.set_announcement(true) .set_announcement(true)
.perform(); .perform();
} }
if (this->started_streaming_tts_) {
// Haven't reached the TTS_END stage, so send the stop signal to HA.
this->signal_stop_();
}
#endif #endif
break; break;
case State::RESPONSE_FINISHED: case State::RESPONSE_FINISHED:
@ -648,13 +673,16 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
if (this->media_player_ != nullptr) { if (this->media_player_ != nullptr) {
for (const auto &arg : msg.data) { for (const auto &arg : msg.data) {
if ((arg.name == "tts_start_streaming") && (arg.value == "1") && !this->tts_response_url_.empty()) { if ((arg.name == "tts_start_streaming") && (arg.value == "1") && !this->tts_response_url_.empty()) {
this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
this->media_player_->make_call().set_media_url(this->tts_response_url_).set_announcement(true).perform(); this->media_player_->make_call().set_media_url(this->tts_response_url_).set_announcement(true).perform();
this->media_player_wait_for_announcement_start_ = true;
this->media_player_wait_for_announcement_end_ = false;
this->started_streaming_tts_ = true; this->started_streaming_tts_ = true;
this->start_playback_timeout_();
tts_url_for_trigger = this->tts_response_url_; tts_url_for_trigger = this->tts_response_url_;
this->tts_response_url_.clear(); // Reset streaming URL this->tts_response_url_.clear(); // Reset streaming URL
this->set_state_(State::STREAMING_RESPONSE, State::STREAMING_RESPONSE);
} }
} }
} }
@ -713,18 +741,22 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
this->defer([this, url]() { this->defer([this, url]() {
#ifdef USE_MEDIA_PLAYER #ifdef USE_MEDIA_PLAYER
if ((this->media_player_ != nullptr) && (!this->started_streaming_tts_)) { if ((this->media_player_ != nullptr) && (!this->started_streaming_tts_)) {
this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
this->media_player_->make_call().set_media_url(url).set_announcement(true).perform(); this->media_player_->make_call().set_media_url(url).set_announcement(true).perform();
this->media_player_wait_for_announcement_start_ = true;
this->media_player_wait_for_announcement_end_ = false;
// Start the playback timeout, as the media player state isn't immediately updated
this->start_playback_timeout_(); this->start_playback_timeout_();
} }
this->started_streaming_tts_ = false; // Helps indicate reaching the TTS_END stage
#endif #endif
this->tts_end_trigger_->trigger(url); this->tts_end_trigger_->trigger(url);
}); });
State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE; State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE;
this->set_state_(new_state, new_state); if (new_state != this->state_) {
// Don't needlessly change the state. The intent progress stage may have already changed the state to streaming
// response.
this->set_state_(new_state, new_state);
}
break; break;
} }
case api::enums::VOICE_ASSISTANT_RUN_END: { case api::enums::VOICE_ASSISTANT_RUN_END: {
@ -875,6 +907,9 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
#ifdef USE_MEDIA_PLAYER #ifdef USE_MEDIA_PLAYER
if (this->media_player_ != nullptr) { if (this->media_player_ != nullptr) {
this->tts_start_trigger_->trigger(msg.text); this->tts_start_trigger_->trigger(msg.text);
this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
if (!msg.preannounce_media_id.empty()) { if (!msg.preannounce_media_id.empty()) {
this->media_player_->make_call().set_media_url(msg.preannounce_media_id).set_announcement(true).perform(); this->media_player_->make_call().set_media_url(msg.preannounce_media_id).set_announcement(true).perform();
} }
@ -886,9 +921,6 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
.perform(); .perform();
this->continue_conversation_ = msg.start_conversation; this->continue_conversation_ = msg.start_conversation;
this->media_player_wait_for_announcement_start_ = true;
this->media_player_wait_for_announcement_end_ = false;
// Start the playback timeout, as the media player state isn't immediately updated
this->start_playback_timeout_(); this->start_playback_timeout_();
if (this->continuous_) { if (this->continuous_) {

View File

@ -90,6 +90,15 @@ struct Configuration {
uint32_t max_active_wake_words; uint32_t max_active_wake_words;
}; };
#ifdef USE_MEDIA_PLAYER
enum class MediaPlayerResponseState {
IDLE,
URL_SENT,
PLAYING,
FINISHED,
};
#endif
class VoiceAssistant : public Component { class VoiceAssistant : public Component {
public: public:
VoiceAssistant(); VoiceAssistant();
@ -272,8 +281,8 @@ class VoiceAssistant : public Component {
media_player::MediaPlayer *media_player_{nullptr}; media_player::MediaPlayer *media_player_{nullptr};
std::string tts_response_url_{""}; std::string tts_response_url_{""};
bool started_streaming_tts_{false}; bool started_streaming_tts_{false};
bool media_player_wait_for_announcement_start_{false};
bool media_player_wait_for_announcement_end_{false}; MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE};
#endif #endif
bool local_output_{false}; bool local_output_{false};

View File

@ -1620,7 +1620,9 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa
request->send(404); request->send(404);
} }
static std::string get_event_type(event::Event *event) { return event->last_event_type ? *event->last_event_type : ""; } static std::string get_event_type(event::Event *event) {
return (event && event->last_event_type) ? *event->last_event_type : "";
}
std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) { std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) {
auto *event = static_cast<event::Event *>(source); auto *event = static_cast<event::Event *>(source);

View File

@ -8,6 +8,7 @@
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/time.h" #include "esphome/core/time.h"
#include "esphome/components/network/util.h" #include "esphome/components/network/util.h"
#include "esphome/core/helpers.h"
#include <esp_wireguard.h> #include <esp_wireguard.h>
#include <esp_wireguard_err.h> #include <esp_wireguard_err.h>
@ -42,7 +43,10 @@ void Wireguard::setup() {
this->publish_enabled_state(); this->publish_enabled_state();
this->wg_initialized_ = esp_wireguard_init(&(this->wg_config_), &(this->wg_ctx_)); {
LwIPLock lock;
this->wg_initialized_ = esp_wireguard_init(&(this->wg_config_), &(this->wg_ctx_));
}
if (this->wg_initialized_ == ESP_OK) { if (this->wg_initialized_ == ESP_OK) {
ESP_LOGI(TAG, "Initialized"); ESP_LOGI(TAG, "Initialized");
@ -249,7 +253,10 @@ void Wireguard::start_connection_() {
} }
ESP_LOGD(TAG, "Starting connection"); ESP_LOGD(TAG, "Starting connection");
this->wg_connected_ = esp_wireguard_connect(&(this->wg_ctx_)); {
LwIPLock lock;
this->wg_connected_ = esp_wireguard_connect(&(this->wg_ctx_));
}
if (this->wg_connected_ == ESP_OK) { if (this->wg_connected_ == ESP_OK) {
ESP_LOGI(TAG, "Connection started"); ESP_LOGI(TAG, "Connection started");
@ -280,7 +287,10 @@ void Wireguard::start_connection_() {
void Wireguard::stop_connection_() { void Wireguard::stop_connection_() {
if (this->wg_initialized_ == ESP_OK && this->wg_connected_ == ESP_OK) { if (this->wg_initialized_ == ESP_OK && this->wg_connected_ == ESP_OK) {
ESP_LOGD(TAG, "Stopping connection"); ESP_LOGD(TAG, "Stopping connection");
esp_wireguard_disconnect(&(this->wg_ctx_)); {
LwIPLock lock;
esp_wireguard_disconnect(&(this->wg_ctx_));
}
this->wg_connected_ = ESP_FAIL; this->wg_connected_ = ESP_FAIL;
} }
} }

View File

@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2025.7.1" __version__ = "2025.7.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@ -158,14 +158,14 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...); auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
this->num_running_++; this->num_running_++;
this->set_timeout(this->delay_.value(x...), f); this->set_timeout("delay", this->delay_.value(x...), f);
} }
float get_setup_priority() const override { return setup_priority::HARDWARE; } float get_setup_priority() const override { return setup_priority::HARDWARE; }
void play(Ts... x) override { /* ignore - see play_complex */ void play(Ts... x) override { /* ignore - see play_complex */
} }
void stop() override { this->cancel_timeout(""); } void stop() override { this->cancel_timeout("delay"); }
}; };
template<typename... Ts> class LambdaAction : public Action<Ts...> { template<typename... Ts> class LambdaAction : public Action<Ts...> {

View File

@ -252,10 +252,10 @@ void Component::defer(const char *name, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, name, 0, std::move(f)); App.scheduler.set_timeout(this, name, 0, std::move(f));
} }
void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // NOLINT
App.scheduler.set_timeout(this, "", timeout, std::move(f)); App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f));
} }
void Component::set_interval(uint32_t interval, std::function<void()> &&f) { // NOLINT void Component::set_interval(uint32_t interval, std::function<void()> &&f) { // NOLINT
App.scheduler.set_interval(this, "", interval, std::move(f)); App.scheduler.set_interval(this, static_cast<const char *>(nullptr), interval, std::move(f));
} }
void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> &&f, void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> &&f,
float backoff_increase_factor) { // NOLINT float backoff_increase_factor) { // NOLINT

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#if defined(USE_ESP32) || defined(USE_LIBRETINY) #if defined(USE_ESP32)
#include <atomic> #include <atomic>
#include <cstddef> #include <cstddef>
@ -78,4 +78,4 @@ template<class T, uint8_t SIZE> class EventPool {
} // namespace esphome } // namespace esphome
#endif // defined(USE_ESP32) || defined(USE_LIBRETINY) #endif // defined(USE_ESP32)

View File

@ -1,17 +1,12 @@
#pragma once #pragma once
#if defined(USE_ESP32) || defined(USE_LIBRETINY) #if defined(USE_ESP32)
#include <atomic> #include <atomic>
#include <cstddef> #include <cstddef>
#if defined(USE_ESP32)
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
#elif defined(USE_LIBRETINY)
#include <FreeRTOS.h>
#include <task.h>
#endif
/* /*
* Lock-free queue for single-producer single-consumer scenarios. * Lock-free queue for single-producer single-consumer scenarios.
@ -148,4 +143,4 @@ template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQu
} // namespace esphome } // namespace esphome
#endif // defined(USE_ESP32) || defined(USE_LIBRETINY) #endif // defined(USE_ESP32)

View File

@ -446,7 +446,7 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co
// Helper to cancel items by name - must be called with lock held // Helper to cancel items by name - must be called with lock held
bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) { bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) {
// Early return if name is invalid - no items to cancel // Early return if name is invalid - no items to cancel
if (name_cstr == nullptr || name_cstr[0] == '\0') { if (name_cstr == nullptr) {
return false; return false;
} }

View File

@ -114,16 +114,17 @@ class Scheduler {
name_is_dynamic = false; name_is_dynamic = false;
} }
if (!name || !name[0]) { if (!name) {
// nullptr case - no name provided
name_.static_name = nullptr; name_.static_name = nullptr;
} else if (make_copy) { } else if (make_copy) {
// Make a copy for dynamic strings // Make a copy for dynamic strings (including empty strings)
size_t len = strlen(name); size_t len = strlen(name);
name_.dynamic_name = new char[len + 1]; name_.dynamic_name = new char[len + 1];
memcpy(name_.dynamic_name, name, len + 1); memcpy(name_.dynamic_name, name, len + 1);
name_is_dynamic = true; name_is_dynamic = true;
} else { } else {
// Use static string directly // Use static string directly (including empty strings)
name_.static_name = name; name_.static_name = name;
} }
} }

View File

@ -138,7 +138,7 @@ lib_deps =
WiFi ; wifi,web_server_base,ethernet (Arduino built-in) WiFi ; wifi,web_server_base,ethernet (Arduino built-in)
Update ; ota,web_server_base (Arduino built-in) Update ; ota,web_server_base (Arduino built-in)
${common:arduino.lib_deps} ${common:arduino.lib_deps}
ESP32Async/AsyncTCP@3.4.4 ; async_tcp ESP32Async/AsyncTCP@3.4.5 ; async_tcp
NetworkClientSecure ; http_request,nextion (Arduino built-in) NetworkClientSecure ; http_request,nextion (Arduino built-in)
HTTPClient ; http_request,nextion (Arduino built-in) HTTPClient ; http_request,nextion (Arduino built-in)
ESPmDNS ; mdns (Arduino built-in) ESPmDNS ; mdns (Arduino built-in)

View File

@ -0,0 +1,18 @@
logger:
id: logger_id
level: DEBUG
on_message:
- level: DEBUG
then:
- lambda: |-
ESP_LOGD("test", "Got message level %d: %s - %s", level, tag, message);
- level: WARN
then:
- lambda: |-
ESP_LOGW("test", "Warning level %d from %s", level, tag);
- level: ERROR
then:
- lambda: |-
// Test that level is uint8_t by using it in calculations
uint8_t adjusted_level = level + 1;
ESP_LOGE("test", "Error with adjusted level %d", adjusted_level);

View File

@ -60,5 +60,28 @@ api:
data: data:
value: !lambda 'return input_float;' value: !lambda 'return input_float;'
# Service that tests char* lambda functionality (e.g., from itoa or sprintf)
- action: test_char_ptr_lambda
variables:
input_number: int
input_string: string
then:
# Log the input to verify service was called
- logger.log:
format: "Service called with number for char* test: %d"
args: [input_number]
# Test that char* lambdas work correctly
# This would fail in issue #9628 with "invalid conversion from 'char*' to 'long long unsigned int'"
- homeassistant.event:
event: esphome.test_char_ptr_lambda
data:
# Test snprintf returning char*
decimal_value: !lambda 'static char buffer[20]; snprintf(buffer, sizeof(buffer), "%d", input_number); return buffer;'
# Test strdup returning char* (dynamically allocated)
string_copy: !lambda 'return strdup(input_string.c_str());'
# Test string literal (const char*)
literal: !lambda 'return "test literal";'
logger: logger:
level: DEBUG level: DEBUG

View File

@ -0,0 +1,24 @@
esphome:
name: test-delay-action
host:
api:
actions:
- action: start_delay_then_restart
then:
- logger.log: "Starting first script execution"
- script.execute: test_delay_script
- delay: 250ms # Give first script time to start delay
- logger.log: "Restarting script (should cancel first delay)"
- script.execute: test_delay_script
logger:
level: DEBUG
script:
- id: test_delay_script
mode: restart
then:
- logger.log: "Script started, beginning delay"
- delay: 500ms # Long enough that it won't complete before restart
- logger.log: "Delay completed successfully"

View File

@ -4,9 +4,7 @@ esphome:
priority: -100 priority: -100
then: then:
- logger.log: "Starting scheduler string tests" - logger.log: "Starting scheduler string tests"
platformio_options: debug_scheduler: true # Enable scheduler debug logging
build_flags:
- "-DESPHOME_DEBUG_SCHEDULER" # Enable scheduler debug logging
host: host:
api: api:
@ -32,6 +30,12 @@ globals:
- id: results_reported - id: results_reported
type: bool type: bool
initial_value: 'false' initial_value: 'false'
- id: edge_tests_done
type: bool
initial_value: 'false'
- id: empty_cancel_failed
type: bool
initial_value: 'false'
script: script:
- id: test_static_strings - id: test_static_strings
@ -147,12 +151,106 @@ script:
static TestDynamicDeferComponent test_dynamic_defer_component; static TestDynamicDeferComponent test_dynamic_defer_component;
test_dynamic_defer_component.test_dynamic_defer(); test_dynamic_defer_component.test_dynamic_defer();
- id: test_cancellation_edge_cases
then:
- logger.log: "Testing cancellation edge cases"
- lambda: |-
auto *component1 = id(test_sensor1);
// Use a different component for empty string tests to avoid interference
auto *component2 = id(test_sensor2);
// Test 12: Cancel with empty string - regression test for issue #9599
// First create a timeout with empty name on component2 to avoid interference
App.scheduler.set_timeout(component2, "", 500, []() {
ESP_LOGE("test", "ERROR: Empty name timeout fired - it should have been cancelled!");
id(empty_cancel_failed) = true;
});
// Now cancel it - this should work after our fix
bool cancelled_empty = App.scheduler.cancel_timeout(component2, "");
ESP_LOGI("test", "Cancel empty string result: %s (should be true)", cancelled_empty ? "true" : "false");
if (!cancelled_empty) {
ESP_LOGE("test", "ERROR: Failed to cancel empty string timeout!");
id(empty_cancel_failed) = true;
}
// Test 13: Cancel non-existent timeout
bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, "does_not_exist");
ESP_LOGI("test", "Cancel non-existent timeout result: %s",
cancelled_nonexistent ? "true (unexpected!)" : "false (expected)");
// Test 14: Multiple timeouts with same name - only last should execute
for (int i = 0; i < 5; i++) {
App.scheduler.set_timeout(component1, "duplicate_timeout", 200 + i*10, [i]() {
ESP_LOGI("test", "Duplicate timeout %d fired", i);
id(timeout_counter) += 1;
});
}
ESP_LOGI("test", "Created 5 timeouts with same name 'duplicate_timeout'");
// Test 15: Multiple intervals with same name - only last should run
for (int i = 0; i < 3; i++) {
App.scheduler.set_interval(component1, "duplicate_interval", 300, [i]() {
ESP_LOGI("test", "Duplicate interval %d fired", i);
id(interval_counter) += 10; // Large increment to detect multiple
// Cancel after first execution
App.scheduler.cancel_interval(id(test_sensor1), "duplicate_interval");
});
}
ESP_LOGI("test", "Created 3 intervals with same name 'duplicate_interval'");
// Test 16: Cancel with nullptr protection (via empty const char*)
const char* null_name = "";
App.scheduler.set_timeout(component2, null_name, 600, []() {
ESP_LOGE("test", "ERROR: Const char* empty timeout fired - should have been cancelled!");
id(empty_cancel_failed) = true;
});
bool cancelled_const_empty = App.scheduler.cancel_timeout(component2, null_name);
ESP_LOGI("test", "Cancel const char* empty result: %s (should be true)",
cancelled_const_empty ? "true" : "false");
if (!cancelled_const_empty) {
ESP_LOGE("test", "ERROR: Failed to cancel const char* empty timeout!");
id(empty_cancel_failed) = true;
}
// Test 17: Rapid create/cancel/create with same name
App.scheduler.set_timeout(component1, "rapid_test", 5000, []() {
ESP_LOGI("test", "First rapid timeout - should not fire");
id(timeout_counter) += 100;
});
App.scheduler.cancel_timeout(component1, "rapid_test");
App.scheduler.set_timeout(component1, "rapid_test", 250, []() {
ESP_LOGI("test", "Second rapid timeout - should fire");
id(timeout_counter) += 1;
});
// Test 18: Cancel all with a specific name (multiple instances)
// Create multiple with same name
App.scheduler.set_timeout(component1, "multi_cancel", 300, []() {
ESP_LOGI("test", "Multi-cancel timeout 1");
});
App.scheduler.set_timeout(component1, "multi_cancel", 350, []() {
ESP_LOGI("test", "Multi-cancel timeout 2");
});
App.scheduler.set_timeout(component1, "multi_cancel", 400, []() {
ESP_LOGI("test", "Multi-cancel timeout 3 - only this should fire");
id(timeout_counter) += 1;
});
// Note: Each set_timeout with same name cancels the previous one automatically
- id: report_results - id: report_results
then: then:
- lambda: |- - lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d", ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d",
id(timeout_counter), id(interval_counter)); id(timeout_counter), id(interval_counter));
// Check if empty string cancellation test passed
if (id(empty_cancel_failed)) {
ESP_LOGE("test", "ERROR: Empty string cancellation test FAILED!");
} else {
ESP_LOGI("test", "Empty string cancellation test PASSED");
}
sensor: sensor:
- platform: template - platform: template
name: Test Sensor 1 name: Test Sensor 1
@ -189,12 +287,23 @@ interval:
- delay: 0.2s - delay: 0.2s
- script.execute: test_dynamic_strings - script.execute: test_dynamic_strings
# Run cancellation edge case tests after dynamic tests
- interval: 0.2s
then:
- if:
condition:
lambda: 'return id(dynamic_tests_done) && !id(edge_tests_done);'
then:
- lambda: 'id(edge_tests_done) = true;'
- delay: 0.5s
- script.execute: test_cancellation_edge_cases
# Report results after all tests # Report results after all tests
- interval: 0.2s - interval: 0.2s
then: then:
- if: - if:
condition: condition:
lambda: 'return id(dynamic_tests_done) && !id(results_reported);' lambda: 'return id(edge_tests_done) && !id(results_reported);'
then: then:
- lambda: 'id(results_reported) = true;' - lambda: 'id(results_reported) = true;'
- delay: 1s - delay: 1s

View File

@ -19,15 +19,17 @@ async def test_api_string_lambda(
"""Test TemplatableStringValue works with lambdas that return different types.""" """Test TemplatableStringValue works with lambdas that return different types."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Track log messages for all three service calls # Track log messages for all four service calls
string_called_future = loop.create_future() string_called_future = loop.create_future()
int_called_future = loop.create_future() int_called_future = loop.create_future()
float_called_future = loop.create_future() float_called_future = loop.create_future()
char_ptr_called_future = loop.create_future()
# Patterns to match in logs - confirms the lambdas compiled and executed # Patterns to match in logs - confirms the lambdas compiled and executed
string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA") string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA")
int_pattern = re.compile(r"Service called with int: 42") int_pattern = re.compile(r"Service called with int: 42")
float_pattern = re.compile(r"Service called with float: 3\.14") float_pattern = re.compile(r"Service called with float: 3\.14")
char_ptr_pattern = re.compile(r"Service called with number for char\* test: 123")
def check_output(line: str) -> None: def check_output(line: str) -> None:
"""Check log output for expected messages.""" """Check log output for expected messages."""
@ -37,6 +39,8 @@ async def test_api_string_lambda(
int_called_future.set_result(True) int_called_future.set_result(True)
if not float_called_future.done() and float_pattern.search(line): if not float_called_future.done() and float_pattern.search(line):
float_called_future.set_result(True) float_called_future.set_result(True)
if not char_ptr_called_future.done() and char_ptr_pattern.search(line):
char_ptr_called_future.set_result(True)
# Run with log monitoring # Run with log monitoring
async with ( async with (
@ -65,17 +69,28 @@ async def test_api_string_lambda(
) )
assert float_service is not None, "test_float_lambda service not found" assert float_service is not None, "test_float_lambda service not found"
# Execute all three services to test different lambda return types char_ptr_service = next(
(s for s in services if s.name == "test_char_ptr_lambda"), None
)
assert char_ptr_service is not None, "test_char_ptr_lambda service not found"
# Execute all four services to test different lambda return types
client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"}) client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"})
client.execute_service(int_service, {"input_number": 42}) client.execute_service(int_service, {"input_number": 42})
client.execute_service(float_service, {"input_float": 3.14}) client.execute_service(float_service, {"input_float": 3.14})
client.execute_service(
char_ptr_service, {"input_number": 123, "input_string": "test_string"}
)
# Wait for all service log messages # Wait for all service log messages
# This confirms the lambdas compiled successfully and executed # This confirms the lambdas compiled successfully and executed
try: try:
await asyncio.wait_for( await asyncio.wait_for(
asyncio.gather( asyncio.gather(
string_called_future, int_called_future, float_called_future string_called_future,
int_called_future,
float_called_future,
char_ptr_called_future,
), ),
timeout=5.0, timeout=5.0,
) )

View File

@ -0,0 +1,91 @@
"""Test ESPHome automations functionality."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_delay_action_cancellation(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that delay actions can be properly cancelled when script restarts."""
loop = asyncio.get_running_loop()
# Track log messages with timestamps
log_entries: list[tuple[float, str]] = []
script_starts: list[float] = []
delay_completions: list[float] = []
script_restart_logged = False
test_started_time = None
# Patterns to match
test_start_pattern = re.compile(r"Starting first script execution")
script_start_pattern = re.compile(r"Script started, beginning delay")
restart_pattern = re.compile(r"Restarting script \(should cancel first delay\)")
delay_complete_pattern = re.compile(r"Delay completed successfully")
# Future to track when we can check results
second_script_started = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
nonlocal script_restart_logged, test_started_time
current_time = loop.time()
log_entries.append((current_time, line))
if test_start_pattern.search(line):
test_started_time = current_time
elif script_start_pattern.search(line) and test_started_time:
script_starts.append(current_time)
if len(script_starts) == 2 and not second_script_started.done():
second_script_started.set_result(True)
elif restart_pattern.search(line):
script_restart_logged = True
elif delay_complete_pattern.search(line):
delay_completions.append(current_time)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get services
entities, services = await client.list_entities_services()
# Find our test service
test_service = next(
(s for s in services if s.name == "start_delay_then_restart"), None
)
assert test_service is not None, "start_delay_then_restart service not found"
# Execute the test sequence
client.execute_service(test_service, {})
# Wait for the second script to start
await asyncio.wait_for(second_script_started, timeout=5.0)
# Wait for potential delay completion
await asyncio.sleep(0.75) # Original delay was 500ms
# Check results
assert len(script_starts) == 2, (
f"Script should have started twice, but started {len(script_starts)} times"
)
assert script_restart_logged, "Script restart was not logged"
# Verify we got exactly one completion and it happened ~500ms after the second start
assert len(delay_completions) == 1, (
f"Expected 1 delay completion, got {len(delay_completions)}"
)
time_from_second_start = delay_completions[0] - script_starts[1]
assert 0.4 < time_from_second_start < 0.6, (
f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s"
)

View File

@ -103,13 +103,14 @@ async def test_scheduler_heap_stress(
# Wait for all callbacks to execute (should be quick, but give more time for scheduling) # Wait for all callbacks to execute (should be quick, but give more time for scheduling)
try: try:
await asyncio.wait_for(test_complete_future, timeout=60.0) await asyncio.wait_for(test_complete_future, timeout=10.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# Report how many we got # Report how many we got
missing_ids = sorted(set(range(1000)) - executed_callbacks)
pytest.fail( pytest.fail(
f"Stress test timed out. Only {len(executed_callbacks)} of " f"Stress test timed out. Only {len(executed_callbacks)} of "
f"1000 callbacks executed. Missing IDs: " f"1000 callbacks executed. Missing IDs: "
f"{sorted(set(range(1000)) - executed_callbacks)[:10]}..." f"{missing_ids[:20]}... (total missing: {len(missing_ids)})"
) )
# Verify all callbacks executed # Verify all callbacks executed