Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe4ffa0cf | ||
|
|
576ce7ee35 | ||
|
|
8a45e877bb | ||
|
|
84607c1255 | ||
|
|
8664ec0a3b | ||
|
|
32d8c60a0b | ||
|
|
976a1e27b4 | ||
|
|
cc2c1b1d89 | ||
|
|
85495d38b7 | ||
|
|
84a77ee427 | ||
|
|
11a4115e30 | ||
|
|
121ed687f3 | ||
|
|
c602f3082e | ||
|
|
4a43f922c6 | ||
|
|
21e66b76e4 | ||
|
|
cdeed7afa7 | ||
|
|
1a9f02fa63 | ||
|
|
7ad1b039f9 | ||
|
|
e255d73c29 | ||
|
|
46f5c44b37 | ||
|
|
9d80889bc9 | ||
|
|
08a5ba6ef1 | ||
|
|
28128c65e5 | ||
|
|
efcad565ee | ||
|
|
cd987feb5b |
2
Doxyfile
2
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.0
|
||||
PROJECT_NUMBER = 2025.7.2
|
||||
|
||||
# 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
|
||||
|
||||
@@ -11,6 +11,18 @@ namespace esphome {
|
||||
namespace api {
|
||||
|
||||
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
|
||||
private:
|
||||
// Helper to convert value to string - handles the case where value is already a string
|
||||
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
|
||||
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 std::string &val) { return val; }
|
||||
static std::string value_to_string(std::string &&val) { return std::move(val); }
|
||||
|
||||
public:
|
||||
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
|
||||
|
||||
@@ -19,7 +31,7 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
|
||||
|
||||
template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
|
||||
TemplatableStringValue(F f)
|
||||
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return to_string(f(x...)); }) {}
|
||||
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
|
||||
};
|
||||
|
||||
template<typename... Ts> class TemplatableKeyValuePair {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/components/network/ip_address.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/util.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <lwip/igmp.h>
|
||||
#include <lwip/init.h>
|
||||
@@ -71,7 +72,11 @@ bool E131Component::join_igmp_groups_() {
|
||||
ip4_addr_t multicast_addr =
|
||||
network::IPAddress(239, 255, ((universe.first >> 8) & 0xff), ((universe.first >> 0) & 0xff));
|
||||
|
||||
auto err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
|
||||
err_t err;
|
||||
{
|
||||
LwIPLock lock;
|
||||
err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe.first);
|
||||
@@ -104,6 +109,7 @@ void E131Component::leave_(int universe) {
|
||||
if (listen_method_ == E131_MULTICAST) {
|
||||
ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff));
|
||||
|
||||
LwIPLock lock;
|
||||
igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -30,6 +31,45 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
|
||||
IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
|
||||
IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); }
|
||||
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
#include "lwip/priv/tcpip_priv.h"
|
||||
#endif
|
||||
|
||||
LwIPLock::LwIPLock() {
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
// When CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, lwIP uses a global mutex to protect
|
||||
// its internal state. Any thread can take this lock to safely access lwIP APIs.
|
||||
//
|
||||
// sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) returns true if the current thread
|
||||
// already holds the lwIP core lock. This prevents recursive locking attempts and
|
||||
// allows nested LwIPLock instances to work correctly.
|
||||
//
|
||||
// If we don't already hold the lock, acquire it. This will block until the lock
|
||||
// is available if another thread currently holds it.
|
||||
if (!sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
|
||||
LOCK_TCPIP_CORE();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
LwIPLock::~LwIPLock() {
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
// Only release the lwIP core lock if this thread currently holds it.
|
||||
//
|
||||
// sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) queries lwIP's internal lock
|
||||
// ownership tracking. It returns true only if the current thread is registered
|
||||
// as the lock holder.
|
||||
//
|
||||
// This check is essential because:
|
||||
// 1. We may not have acquired the lock in the constructor (if we already held it)
|
||||
// 2. The lock might have been released by other means between constructor and destructor
|
||||
// 3. Calling UNLOCK_TCPIP_CORE() without holding the lock causes undefined behavior
|
||||
if (sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
|
||||
UNLOCK_TCPIP_CORE();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
|
||||
// When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
@@ -8,6 +10,7 @@ from esphome.const import (
|
||||
CONF_CONTRAST,
|
||||
CONF_DATA_PINS,
|
||||
CONF_FREQUENCY,
|
||||
CONF_I2C,
|
||||
CONF_I2C_ID,
|
||||
CONF_ID,
|
||||
CONF_PIN,
|
||||
@@ -20,6 +23,9 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core.entity_helpers import setup_entity
|
||||
import esphome.final_validate as fv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ["esp32"]
|
||||
|
||||
@@ -250,6 +256,22 @@ CONFIG_SCHEMA = cv.All(
|
||||
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 = {
|
||||
# pin assignment
|
||||
CONF_DATA_PINS: "set_data_pins",
|
||||
|
||||
@@ -22,6 +22,10 @@ void Mutex::unlock() {}
|
||||
IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); }
|
||||
IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); }
|
||||
|
||||
// ESP8266 doesn't support lwIP core locking, so this is a no-op
|
||||
LwIPLock::LwIPLock() {}
|
||||
LwIPLock::~LwIPLock() {}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
wifi_get_macaddr(STATION_IF, mac);
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() {
|
||||
}
|
||||
|
||||
network::IPAddress EthernetComponent::get_dns_address(uint8_t num) {
|
||||
LwIPLock lock;
|
||||
const ip_addr_t *dns_ip = dns_getserver(num);
|
||||
return dns_ip;
|
||||
}
|
||||
@@ -527,6 +528,7 @@ void EthernetComponent::start_connect_() {
|
||||
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
|
||||
|
||||
if (this->manual_ip_.has_value()) {
|
||||
LwIPLock lock;
|
||||
if (this->manual_ip_->dns1.is_set()) {
|
||||
ip_addr_t d;
|
||||
d = this->manual_ip_->dns1;
|
||||
@@ -559,8 +561,13 @@ bool EthernetComponent::is_connected() { return this->state_ == EthernetComponen
|
||||
void EthernetComponent::dump_connect_params_() {
|
||||
esp_netif_ip_info_t ip;
|
||||
esp_netif_get_ip_info(this->eth_netif_, &ip);
|
||||
const ip_addr_t *dns_ip1 = dns_getserver(0);
|
||||
const ip_addr_t *dns_ip2 = dns_getserver(1);
|
||||
const ip_addr_t *dns_ip1;
|
||||
const ip_addr_t *dns_ip2;
|
||||
{
|
||||
LwIPLock lock;
|
||||
dns_ip1 = dns_getserver(0);
|
||||
dns_ip2 = dns_getserver(1);
|
||||
}
|
||||
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" IP Address: %s\n"
|
||||
|
||||
@@ -29,7 +29,21 @@ CONFIG_SCHEMA = (
|
||||
.extend(
|
||||
{
|
||||
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(
|
||||
INTERRUPT_TYPES, upper=True
|
||||
),
|
||||
|
||||
@@ -26,6 +26,10 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
|
||||
IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
|
||||
IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); }
|
||||
|
||||
// LibreTiny doesn't support lwIP core locking, so this is a no-op
|
||||
LwIPLock::LwIPLock() {}
|
||||
LwIPLock::~LwIPLock() {}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
WiFi.macAddress(mac);
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ def validate_local_no_higher_than_global(value):
|
||||
Logger = logger_ns.class_("Logger", cg.Component)
|
||||
LoggerMessageTrigger = logger_ns.class_(
|
||||
"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"
|
||||
@@ -368,7 +368,7 @@ async def to_code(config):
|
||||
await automation.build_automation(
|
||||
trigger,
|
||||
[
|
||||
(cg.int_, "level"),
|
||||
(cg.uint8, "level"),
|
||||
(cg.const_char_ptr, "tag"),
|
||||
(cg.const_char_ptr, "message"),
|
||||
],
|
||||
|
||||
@@ -192,7 +192,7 @@ class WidgetType:
|
||||
|
||||
class NumberType(WidgetType):
|
||||
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):
|
||||
return int(config[CONF_MIN_VALUE] or 0)
|
||||
return int(config.get(CONF_MIN_VALUE, 0))
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
CONF_VALUE,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.cpp_generator import IntLiteral
|
||||
|
||||
from ..automation import action_to_code
|
||||
from ..defines import (
|
||||
@@ -188,6 +189,8 @@ class MeterType(WidgetType):
|
||||
rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2
|
||||
if CONF_ROTATION in scale_conf:
|
||||
rotation = await lv_angle.process(scale_conf[CONF_ROTATION])
|
||||
if isinstance(rotation, IntLiteral):
|
||||
rotation = int(str(rotation)) // 10
|
||||
with LocalVariable(
|
||||
"meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var)
|
||||
) as meter_var:
|
||||
@@ -264,7 +267,7 @@ class MeterType(WidgetType):
|
||||
color_start,
|
||||
color_end,
|
||||
v[CONF_LOCAL],
|
||||
size.process(v[CONF_WIDTH]),
|
||||
await size.process(v[CONF_WIDTH]),
|
||||
),
|
||||
)
|
||||
if t == CONF_IMAGE:
|
||||
|
||||
@@ -193,13 +193,17 @@ void MQTTClientComponent::start_dnslookup_() {
|
||||
this->dns_resolve_error_ = false;
|
||||
this->dns_resolved_ = false;
|
||||
ip_addr_t addr;
|
||||
err_t err;
|
||||
{
|
||||
LwIPLock lock;
|
||||
#if USE_NETWORK_IPV6
|
||||
err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr,
|
||||
MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV6_IPV4);
|
||||
err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
|
||||
this, LWIP_DNS_ADDRTYPE_IPV6_IPV4);
|
||||
#else
|
||||
err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr,
|
||||
MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV4);
|
||||
err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
|
||||
this, LWIP_DNS_ADDRTYPE_IPV4);
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
}
|
||||
switch (err) {
|
||||
case ERR_OK: {
|
||||
// Got IP immediately
|
||||
|
||||
@@ -44,6 +44,10 @@ void Mutex::unlock() {}
|
||||
IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); }
|
||||
IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
|
||||
|
||||
// RP2040 doesn't support lwIP core locking, so this is a no-op
|
||||
LwIPLock::LwIPLock() {}
|
||||
LwIPLock::~LwIPLock() {}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
#ifdef USE_WIFI
|
||||
WiFi.macAddress(mac);
|
||||
|
||||
@@ -200,7 +200,7 @@ AudioPipelineState AudioPipeline::process_state() {
|
||||
if ((this->read_task_handle_ != nullptr) || (this->decode_task_handle_ != nullptr)) {
|
||||
this->delete_tasks_();
|
||||
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->hard_stop_ = false;
|
||||
} else {
|
||||
@@ -210,13 +210,25 @@ AudioPipelineState AudioPipeline::process_state() {
|
||||
}
|
||||
}
|
||||
this->is_playing_ = false;
|
||||
return AudioPipelineState::STOPPED;
|
||||
if (!this->speaker_->is_running()) {
|
||||
return AudioPipelineState::STOPPED;
|
||||
} else {
|
||||
this->is_finishing_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pause_state_) {
|
||||
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)) {
|
||||
// No tasks are running, so the pipeline is stopped.
|
||||
xEventGroupClearBits(this->event_group_, EventGroupBits::PIPELINE_COMMAND_STOP);
|
||||
|
||||
@@ -114,6 +114,7 @@ class AudioPipeline {
|
||||
|
||||
bool hard_stop_{false};
|
||||
bool is_playing_{false};
|
||||
bool is_finishing_{false};
|
||||
bool pause_state_{false};
|
||||
bool task_stack_in_psram_;
|
||||
|
||||
|
||||
@@ -35,6 +35,27 @@ void VoiceAssistant::setup() {
|
||||
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; }
|
||||
@@ -223,6 +244,13 @@ void VoiceAssistant::loop() {
|
||||
msg.wake_word_phrase = 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)) {
|
||||
ESP_LOGW(TAG, "Could not request start");
|
||||
this->error_trigger_->trigger("not-connected", "Could not request start");
|
||||
@@ -314,17 +342,10 @@ void VoiceAssistant::loop() {
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
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_) {
|
||||
// Announcement has started playing, wait for it to finish
|
||||
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;
|
||||
if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) {
|
||||
this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
|
||||
this->cancel_timeout("playing");
|
||||
ESP_LOGD(TAG, "Announcement finished playing");
|
||||
this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
|
||||
@@ -555,7 +576,7 @@ void VoiceAssistant::request_stop() {
|
||||
break;
|
||||
case State::AWAITING_RESPONSE:
|
||||
this->signal_stop_();
|
||||
// Fallthrough intended to stop a streaming TTS announcement that has potentially started
|
||||
break;
|
||||
case State::STREAMING_RESPONSE:
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
// Stop any ongoing media player announcement
|
||||
@@ -565,6 +586,10 @@ void VoiceAssistant::request_stop() {
|
||||
.set_announcement(true)
|
||||
.perform();
|
||||
}
|
||||
if (this->started_streaming_tts_) {
|
||||
// Haven't reached the TTS_END stage, so send the stop signal to HA.
|
||||
this->signal_stop_();
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
case State::RESPONSE_FINISHED:
|
||||
@@ -648,13 +673,16 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
|
||||
if (this->media_player_ != nullptr) {
|
||||
for (const auto &arg : msg.data) {
|
||||
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_wait_for_announcement_start_ = true;
|
||||
this->media_player_wait_for_announcement_end_ = false;
|
||||
this->started_streaming_tts_ = true;
|
||||
this->start_playback_timeout_();
|
||||
|
||||
tts_url_for_trigger = this->tts_response_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]() {
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
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_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->started_streaming_tts_ = false; // Helps indicate reaching the TTS_END stage
|
||||
#endif
|
||||
this->tts_end_trigger_->trigger(url);
|
||||
});
|
||||
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;
|
||||
}
|
||||
case api::enums::VOICE_ASSISTANT_RUN_END: {
|
||||
@@ -875,6 +907,9 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
if (this->media_player_ != nullptr) {
|
||||
this->tts_start_trigger_->trigger(msg.text);
|
||||
|
||||
this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
|
||||
|
||||
if (!msg.preannounce_media_id.empty()) {
|
||||
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();
|
||||
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_();
|
||||
|
||||
if (this->continuous_) {
|
||||
|
||||
@@ -90,6 +90,15 @@ struct Configuration {
|
||||
uint32_t max_active_wake_words;
|
||||
};
|
||||
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
enum class MediaPlayerResponseState {
|
||||
IDLE,
|
||||
URL_SENT,
|
||||
PLAYING,
|
||||
FINISHED,
|
||||
};
|
||||
#endif
|
||||
|
||||
class VoiceAssistant : public Component {
|
||||
public:
|
||||
VoiceAssistant();
|
||||
@@ -272,8 +281,8 @@ class VoiceAssistant : public Component {
|
||||
media_player::MediaPlayer *media_player_{nullptr};
|
||||
std::string tts_response_url_{""};
|
||||
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
|
||||
|
||||
bool local_output_{false};
|
||||
|
||||
@@ -74,13 +74,14 @@ def validate_local(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def validate_ota_removed(config: ConfigType) -> ConfigType:
|
||||
# Only raise error if OTA is explicitly enabled (True)
|
||||
# If it's False or not specified, we can safely ignore it
|
||||
if config.get(CONF_OTA):
|
||||
def validate_ota(config: ConfigType) -> ConfigType:
|
||||
# The OTA option only accepts False to explicitly disable OTA for web_server
|
||||
# IMPORTANT: Setting ota: false ONLY affects the web_server component
|
||||
# The captive_portal component will still be able to perform OTA updates
|
||||
if CONF_OTA in config and config[CONF_OTA] is not False:
|
||||
raise cv.Invalid(
|
||||
f"The '{CONF_OTA}' option has been removed from 'web_server'. "
|
||||
f"Please use the new OTA platform structure instead:\n\n"
|
||||
f"The '{CONF_OTA}' option in 'web_server' only accepts 'false' to disable OTA. "
|
||||
f"To enable OTA, please use the new OTA platform structure instead:\n\n"
|
||||
f"ota:\n"
|
||||
f" - platform: web_server\n\n"
|
||||
f"See https://esphome.io/components/ota for more information."
|
||||
@@ -185,7 +186,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
web_server_base.WebServerBase
|
||||
),
|
||||
cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean,
|
||||
cv.Optional(CONF_OTA, default=False): cv.boolean,
|
||||
cv.Optional(CONF_OTA): cv.boolean,
|
||||
cv.Optional(CONF_LOG, default=True): cv.boolean,
|
||||
cv.Optional(CONF_LOCAL): cv.boolean,
|
||||
cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group),
|
||||
@@ -203,7 +204,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
default_url,
|
||||
validate_local,
|
||||
validate_sorting_groups,
|
||||
validate_ota_removed,
|
||||
validate_ota,
|
||||
)
|
||||
|
||||
|
||||
@@ -288,7 +289,11 @@ async def to_code(config):
|
||||
cg.add(var.set_css_url(config[CONF_CSS_URL]))
|
||||
cg.add(var.set_js_url(config[CONF_JS_URL]))
|
||||
# OTA is now handled by the web_server OTA platform
|
||||
# The CONF_OTA option is kept only for backwards compatibility validation
|
||||
# The CONF_OTA option is kept to allow explicitly disabling OTA for web_server
|
||||
# IMPORTANT: This ONLY affects the web_server component, NOT captive_portal
|
||||
# Captive portal will still be able to perform OTA updates even when this is set
|
||||
if config.get(CONF_OTA) is False:
|
||||
cg.add_define("USE_WEBSERVER_OTA_DISABLED")
|
||||
cg.add(var.set_expose_log(config[CONF_LOG]))
|
||||
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
|
||||
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
#include "esphome/components/captive_portal/captive_portal.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_ARDUINO
|
||||
#ifdef USE_ESP8266
|
||||
#include <Updater.h>
|
||||
@@ -25,7 +29,22 @@ class OTARequestHandler : public AsyncWebHandler {
|
||||
void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
|
||||
bool final) override;
|
||||
bool canHandle(AsyncWebServerRequest *request) const override {
|
||||
return request->url() == "/update" && request->method() == HTTP_POST;
|
||||
// Check if this is an OTA update request
|
||||
bool is_ota_request = request->url() == "/update" && request->method() == HTTP_POST;
|
||||
|
||||
#if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL)
|
||||
// IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
|
||||
// Captive portal can still perform OTA updates - check if request is from active captive portal
|
||||
// Note: global_captive_portal is the standard way components communicate in ESPHome
|
||||
return is_ota_request && captive_portal::global_captive_portal != nullptr &&
|
||||
captive_portal::global_captive_portal->is_active();
|
||||
#elif defined(USE_WEBSERVER_OTA_DISABLED)
|
||||
// OTA disabled for web_server and no captive portal compiled in
|
||||
return false;
|
||||
#else
|
||||
// OTA enabled for web_server
|
||||
return is_ota_request;
|
||||
#endif
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
@@ -152,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin
|
||||
|
||||
// Finalize
|
||||
if (final) {
|
||||
ESP_LOGD(TAG, "OTA final chunk: index=%u, len=%u, total_read=%u, contentLength=%u", index, len,
|
||||
ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%u, contentLength=%zu", index, len,
|
||||
this->ota_read_length_, request->contentLength());
|
||||
|
||||
// For Arduino framework, the Update library tracks expected size from firmware header
|
||||
|
||||
@@ -268,10 +268,10 @@ std::string WebServer::get_config_json() {
|
||||
return json::build_json([this](JsonObject root) {
|
||||
root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name();
|
||||
root["comment"] = App.get_comment();
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
root["ota"] = true; // web_server OTA platform is configured
|
||||
#if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA)
|
||||
root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal
|
||||
#else
|
||||
root["ota"] = false;
|
||||
root["ota"] = true;
|
||||
#endif
|
||||
root["log"] = this->expose_log_;
|
||||
root["lang"] = "en";
|
||||
@@ -1620,7 +1620,9 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa
|
||||
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) {
|
||||
auto *event = static_cast<event::Event *>(source);
|
||||
|
||||
@@ -192,7 +192,9 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
|
||||
|
||||
stream->print(F("</tbody></table><p>See <a href=\"https://esphome.io/web-api/index.html\">ESPHome Web API</a> for "
|
||||
"REST API documentation.</p>"));
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
#if defined(USE_WEBSERVER_OTA) && !defined(USE_WEBSERVER_OTA_DISABLED)
|
||||
// Show OTA form only if web_server OTA is not explicitly disabled
|
||||
// Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal
|
||||
stream->print(F("<h2>OTA Update</h2><form method=\"POST\" action=\"/update\" enctype=\"multipart/form-data\"><input "
|
||||
"type=\"file\" name=\"update\"><input type=\"submit\" value=\"Update\"></form>"));
|
||||
#endif
|
||||
|
||||
@@ -20,10 +20,6 @@
|
||||
#include "lwip/dns.h"
|
||||
#include "lwip/err.h"
|
||||
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
#include "lwip/priv/tcpip_priv.h"
|
||||
#endif
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -295,25 +291,16 @@ bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
|
||||
}
|
||||
|
||||
if (!manual_ip.has_value()) {
|
||||
// sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!)
|
||||
// https://github.com/esphome/issues/issues/6591
|
||||
// https://github.com/espressif/arduino-esp32/issues/10526
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
if (!sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
|
||||
LOCK_TCPIP_CORE();
|
||||
// sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!)
|
||||
// https://github.com/esphome/issues/issues/6591
|
||||
// https://github.com/espressif/arduino-esp32/issues/10526
|
||||
{
|
||||
LwIPLock lock;
|
||||
// lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly,
|
||||
// the built-in SNTP client has a memory leak in certain situations. Disable this feature.
|
||||
// https://github.com/esphome/issues/issues/2299
|
||||
sntp_servermode_dhcp(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
// lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly,
|
||||
// the built-in SNTP client has a memory leak in certain situations. Disable this feature.
|
||||
// https://github.com/esphome/issues/issues/2299
|
||||
sntp_servermode_dhcp(false);
|
||||
|
||||
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
|
||||
if (sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
|
||||
UNLOCK_TCPIP_CORE();
|
||||
}
|
||||
#endif
|
||||
|
||||
// No manual IP is set; use DHCP client
|
||||
if (dhcp_status != ESP_NETIF_DHCP_STARTED) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/time.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <esp_wireguard.h>
|
||||
#include <esp_wireguard_err.h>
|
||||
@@ -42,7 +43,10 @@ void Wireguard::setup() {
|
||||
|
||||
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) {
|
||||
ESP_LOGI(TAG, "Initialized");
|
||||
@@ -249,7 +253,10 @@ void Wireguard::start_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) {
|
||||
ESP_LOGI(TAG, "Connection started");
|
||||
@@ -280,7 +287,10 @@ void Wireguard::start_connection_() {
|
||||
void Wireguard::stop_connection_() {
|
||||
if (this->wg_initialized_ == ESP_OK && this->wg_connected_ == ESP_OK) {
|
||||
ESP_LOGD(TAG, "Stopping connection");
|
||||
esp_wireguard_disconnect(&(this->wg_ctx_));
|
||||
{
|
||||
LwIPLock lock;
|
||||
esp_wireguard_disconnect(&(this->wg_ctx_));
|
||||
}
|
||||
this->wg_connected_ = ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2025.7.0"
|
||||
__version__ = "2025.7.2"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -158,14 +158,14 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
|
||||
void play_complex(Ts... x) override {
|
||||
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
|
||||
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; }
|
||||
|
||||
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...> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
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
|
||||
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,
|
||||
float backoff_increase_factor) { // NOLINT
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#if defined(USE_ESP32)
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
@@ -78,4 +78,4 @@ template<class T, uint8_t SIZE> class EventPool {
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#endif // defined(USE_ESP32)
|
||||
|
||||
@@ -683,6 +683,23 @@ class InterruptLock {
|
||||
#endif
|
||||
};
|
||||
|
||||
/** Helper class to lock the lwIP TCPIP core when making lwIP API calls from non-TCPIP threads.
|
||||
*
|
||||
* This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled.
|
||||
* It ensures thread-safe access to lwIP APIs.
|
||||
*
|
||||
* @note This follows the same pattern as InterruptLock - platform-specific implementations in helpers.cpp
|
||||
*/
|
||||
class LwIPLock {
|
||||
public:
|
||||
LwIPLock();
|
||||
~LwIPLock();
|
||||
|
||||
// Delete copy constructor and copy assignment operator to prevent accidental copying
|
||||
LwIPLock(const LwIPLock &) = delete;
|
||||
LwIPLock &operator=(const LwIPLock &) = delete;
|
||||
};
|
||||
|
||||
/** Helper class to request `loop()` to be called as fast as possible.
|
||||
*
|
||||
* Usually the ESPHome main loop runs at 60 Hz, sleeping in between invocations of `loop()` if necessary. When a higher
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#if defined(USE_ESP32)
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
|
||||
#if defined(USE_ESP32)
|
||||
#include <freertos/FreeRTOS.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.
|
||||
@@ -148,4 +143,4 @@ template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQu
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
#endif // defined(USE_ESP32)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
if (name_cstr == nullptr || name_cstr[0] == '\0') {
|
||||
if (name_cstr == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,16 +114,17 @@ class Scheduler {
|
||||
name_is_dynamic = false;
|
||||
}
|
||||
|
||||
if (!name || !name[0]) {
|
||||
if (!name) {
|
||||
// nullptr case - no name provided
|
||||
name_.static_name = nullptr;
|
||||
} else if (make_copy) {
|
||||
// Make a copy for dynamic strings
|
||||
// Make a copy for dynamic strings (including empty strings)
|
||||
size_t len = strlen(name);
|
||||
name_.dynamic_name = new char[len + 1];
|
||||
memcpy(name_.dynamic_name, name, len + 1);
|
||||
name_is_dynamic = true;
|
||||
} else {
|
||||
// Use static string directly
|
||||
// Use static string directly (including empty strings)
|
||||
name_.static_name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,13 @@ class RedirectText:
|
||||
continue
|
||||
|
||||
self._write_color_replace(line)
|
||||
# Check for flash size error and provide helpful guidance
|
||||
if (
|
||||
"Error: The program size" in line
|
||||
and "is greater than maximum allowed" in line
|
||||
and (help_msg := get_esp32_arduino_flash_error_help())
|
||||
):
|
||||
self._write_color_replace(help_msg)
|
||||
else:
|
||||
self._write_color_replace(s)
|
||||
|
||||
@@ -309,3 +316,34 @@ def get_serial_ports() -> list[SerialPort]:
|
||||
|
||||
result.sort(key=lambda x: x.path)
|
||||
return result
|
||||
|
||||
|
||||
def get_esp32_arduino_flash_error_help() -> str | None:
|
||||
"""Returns helpful message when ESP32 with Arduino runs out of flash space."""
|
||||
from esphome.core import CORE
|
||||
|
||||
if not (CORE.is_esp32 and CORE.using_arduino):
|
||||
return None
|
||||
|
||||
from esphome.log import AnsiFore, color
|
||||
|
||||
return (
|
||||
"\n"
|
||||
+ color(
|
||||
AnsiFore.YELLOW,
|
||||
"💡 TIP: Your ESP32 with Arduino framework has run out of flash space.\n",
|
||||
)
|
||||
+ "\n"
|
||||
+ "To fix this, switch to the ESP-IDF framework which is more memory efficient:\n"
|
||||
+ "\n"
|
||||
+ "1. In your YAML configuration, modify the framework section:\n"
|
||||
+ "\n"
|
||||
+ " esp32:\n"
|
||||
+ " framework:\n"
|
||||
+ " type: esp-idf\n"
|
||||
+ "\n"
|
||||
+ "2. Clean build files and compile again\n"
|
||||
+ "\n"
|
||||
+ "Note: ESP-IDF uses less flash space and provides better performance.\n"
|
||||
+ "Some Arduino-specific libraries may need alternatives.\n\n"
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ lib_deps =
|
||||
WiFi ; wifi,web_server_base,ethernet (Arduino built-in)
|
||||
Update ; ota,web_server_base (Arduino built-in)
|
||||
${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)
|
||||
HTTPClient ; http_request,nextion (Arduino built-in)
|
||||
ESPmDNS ; mdns (Arduino built-in)
|
||||
|
||||
@@ -8,31 +8,31 @@ from esphome.types import ConfigType
|
||||
|
||||
def test_web_server_ota_true_fails_validation() -> None:
|
||||
"""Test that web_server with ota: true fails validation with helpful message."""
|
||||
from esphome.components.web_server import validate_ota_removed
|
||||
from esphome.components.web_server import validate_ota
|
||||
|
||||
# Config with ota: true should fail
|
||||
config: ConfigType = {"ota": True}
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
validate_ota_removed(config)
|
||||
validate_ota(config)
|
||||
|
||||
# Check error message contains migration instructions
|
||||
error_msg = str(exc_info.value)
|
||||
assert "has been removed from 'web_server'" in error_msg
|
||||
assert "only accepts 'false' to disable OTA" in error_msg
|
||||
assert "platform: web_server" in error_msg
|
||||
assert "ota:" in error_msg
|
||||
|
||||
|
||||
def test_web_server_ota_false_passes_validation() -> None:
|
||||
"""Test that web_server with ota: false passes validation."""
|
||||
from esphome.components.web_server import validate_ota_removed
|
||||
from esphome.components.web_server import validate_ota
|
||||
|
||||
# Config with ota: false should pass
|
||||
config: ConfigType = {"ota": False}
|
||||
result = validate_ota_removed(config)
|
||||
result = validate_ota(config)
|
||||
assert result == config
|
||||
|
||||
# Config without ota should also pass
|
||||
config: ConfigType = {}
|
||||
result = validate_ota_removed(config)
|
||||
result = validate_ota(config)
|
||||
assert result == config
|
||||
|
||||
18
tests/components/logger/test-on_message.host.yaml
Normal file
18
tests/components/logger/test-on_message.host.yaml
Normal 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);
|
||||
@@ -928,6 +928,12 @@ lvgl:
|
||||
angle_range: 360
|
||||
rotation: !lambda return 2700;
|
||||
indicators:
|
||||
- tick_style:
|
||||
start_value: 0
|
||||
end_value: 60
|
||||
color_start: 0x0000bd
|
||||
color_end: 0xbd0000
|
||||
width: !lambda return 1;
|
||||
- line:
|
||||
opa: 50%
|
||||
id: minute_hand
|
||||
|
||||
87
tests/integration/fixtures/api_string_lambda.yaml
Normal file
87
tests/integration/fixtures/api_string_lambda.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
esphome:
|
||||
name: api-string-lambda-test
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# Service that tests string lambda functionality
|
||||
- action: test_string_lambda
|
||||
variables:
|
||||
input_string: string
|
||||
then:
|
||||
# Log the input to verify service was called
|
||||
- logger.log:
|
||||
format: "Service called with string: %s"
|
||||
args: [input_string.c_str()]
|
||||
|
||||
# This is the key test - using a lambda that returns x.c_str()
|
||||
# where x is already a string. This would fail to compile in 2025.7.0b5
|
||||
# with "no matching function for call to 'to_string(std::string)'"
|
||||
# This is the exact case from issue #9539
|
||||
- homeassistant.tag_scanned: !lambda 'return input_string.c_str();'
|
||||
|
||||
# Also test with homeassistant.event to verify our fix works with data fields
|
||||
- homeassistant.event:
|
||||
event: esphome.test_string_lambda
|
||||
data:
|
||||
value: !lambda 'return input_string.c_str();'
|
||||
|
||||
# Service that tests int lambda functionality
|
||||
- action: test_int_lambda
|
||||
variables:
|
||||
input_number: int
|
||||
then:
|
||||
# Log the input to verify service was called
|
||||
- logger.log:
|
||||
format: "Service called with int: %d"
|
||||
args: [input_number]
|
||||
|
||||
# Test that int lambdas still work correctly with to_string
|
||||
# The TemplatableStringValue should automatically convert int to string
|
||||
- homeassistant.event:
|
||||
event: esphome.test_int_lambda
|
||||
data:
|
||||
value: !lambda 'return input_number;'
|
||||
|
||||
# Service that tests float lambda functionality
|
||||
- action: test_float_lambda
|
||||
variables:
|
||||
input_float: float
|
||||
then:
|
||||
# Log the input to verify service was called
|
||||
- logger.log:
|
||||
format: "Service called with float: %.2f"
|
||||
args: [input_float]
|
||||
|
||||
# Test that float lambdas still work correctly with to_string
|
||||
# The TemplatableStringValue should automatically convert float to string
|
||||
- homeassistant.event:
|
||||
event: esphome.test_float_lambda
|
||||
data:
|
||||
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:
|
||||
level: DEBUG
|
||||
24
tests/integration/fixtures/delay_action_cancellation.yaml
Normal file
24
tests/integration/fixtures/delay_action_cancellation.yaml
Normal 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"
|
||||
@@ -4,9 +4,7 @@ esphome:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "Starting scheduler string tests"
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DESPHOME_DEBUG_SCHEDULER" # Enable scheduler debug logging
|
||||
debug_scheduler: true # Enable scheduler debug logging
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -32,6 +30,12 @@ globals:
|
||||
- id: results_reported
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
- id: edge_tests_done
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
- id: empty_cancel_failed
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: test_static_strings
|
||||
@@ -147,12 +151,106 @@ script:
|
||||
static TestDynamicDeferComponent test_dynamic_defer_component;
|
||||
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
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d",
|
||||
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:
|
||||
- platform: template
|
||||
name: Test Sensor 1
|
||||
@@ -189,12 +287,23 @@ interval:
|
||||
- delay: 0.2s
|
||||
- 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
|
||||
- interval: 0.2s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(dynamic_tests_done) && !id(results_reported);'
|
||||
lambda: 'return id(edge_tests_done) && !id(results_reported);'
|
||||
then:
|
||||
- lambda: 'id(results_reported) = true;'
|
||||
- delay: 1s
|
||||
|
||||
100
tests/integration/test_api_string_lambda.py
Normal file
100
tests/integration/test_api_string_lambda.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Integration test for TemplatableStringValue with string lambdas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_string_lambda(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test TemplatableStringValue works with lambdas that return different types."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track log messages for all four service calls
|
||||
string_called_future = loop.create_future()
|
||||
int_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
|
||||
string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA")
|
||||
int_pattern = re.compile(r"Service called with int: 42")
|
||||
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:
|
||||
"""Check log output for expected messages."""
|
||||
if not string_called_future.done() and string_pattern.search(line):
|
||||
string_called_future.set_result(True)
|
||||
if not int_called_future.done() and int_pattern.search(line):
|
||||
int_called_future.set_result(True)
|
||||
if not float_called_future.done() and float_pattern.search(line):
|
||||
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
|
||||
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-string-lambda-test"
|
||||
|
||||
# List services to find our test services
|
||||
_, services = await client.list_entities_services()
|
||||
|
||||
# Find all test services
|
||||
string_service = next(
|
||||
(s for s in services if s.name == "test_string_lambda"), None
|
||||
)
|
||||
assert string_service is not None, "test_string_lambda service not found"
|
||||
|
||||
int_service = next((s for s in services if s.name == "test_int_lambda"), None)
|
||||
assert int_service is not None, "test_int_lambda service not found"
|
||||
|
||||
float_service = next(
|
||||
(s for s in services if s.name == "test_float_lambda"), None
|
||||
)
|
||||
assert float_service is not None, "test_float_lambda service not found"
|
||||
|
||||
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(int_service, {"input_number": 42})
|
||||
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
|
||||
# This confirms the lambdas compiled successfully and executed
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
string_called_future,
|
||||
int_called_future,
|
||||
float_called_future,
|
||||
char_ptr_called_future,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"One or more service log messages not received - lambda may have failed to compile or execute"
|
||||
)
|
||||
91
tests/integration/test_automations.py
Normal file
91
tests/integration/test_automations.py
Normal 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"
|
||||
)
|
||||
@@ -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)
|
||||
try:
|
||||
await asyncio.wait_for(test_complete_future, timeout=60.0)
|
||||
await asyncio.wait_for(test_complete_future, timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
# Report how many we got
|
||||
missing_ids = sorted(set(range(1000)) - executed_callbacks)
|
||||
pytest.fail(
|
||||
f"Stress test timed out. Only {len(executed_callbacks)} of "
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user