1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-05 17:41:49 +00:00

Compare commits

...

53 Commits

Author SHA1 Message Date
Jesse Hills
68c0aa4d6d Merge pull request #10079 from esphome/bump-2025.7.5
2025.7.5
2025-08-05 15:37:42 +12:00
Jesse Hills
d29cae9c3b Bump version to 2025.7.5 2025-08-05 13:21:00 +12:00
Chris Beswick
532e3e370f [i2s_audio] Use high-pass filter for dc offset correction (#10005) 2025-08-05 13:21:00 +12:00
Clyde Stubbs
da573a217d [font] Catch file load exception (#10058)
Co-authored-by: clydeps <U5yx99dok9>
2025-08-05 13:21:00 +12:00
J. Nick Koston
a9b27d1966 [api] Fix OTA progress updates not being sent when main loop is blocked (#10049) 2025-08-05 13:21:00 +12:00
Clyde Stubbs
0aa3c9685e [lvgl] Bugfix for tileview (#9938) 2025-08-05 13:21:00 +12:00
Jesse Hills
d6b222c370 Merge pull request #9933 from esphome/bump-2025.7.4
2025.7.4
2025-07-28 19:33:19 +12:00
Jesse Hills
573dad1736 Bump version to 2025.7.4 2025-07-28 15:55:07 +12:00
Jimmy Hedman
3a6cc0ea3d Fail with old lerp (#9914)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-07-28 15:55:07 +12:00
cryptk
2f9475a927 Add seed flag when running setup with uv present (#9932) 2025-07-28 15:55:07 +12:00
Jesse Hills
8dce7b0905 [logger] Don't allow `logger.log actions without configuring the logger` (#9821) 2025-07-28 15:55:07 +12:00
Eric Hoffmann
8b0ad3072f fix: non-optional x/y target calculation for ld2450 (#9849) 2025-07-28 15:55:07 +12:00
Clyde Stubbs
93028a4d90 [gt911] i2c fixes (#9822) 2025-07-28 15:55:07 +12:00
Jonathan Swoboda
c9793f3741 [remote_receiver] Fix idle validation (#9819) 2025-07-28 15:55:07 +12:00
Jesse Hills
2b5cceda58 Merge pull request #9796 from esphome/bump-2025.7.3
2025.7.3
2025-07-23 08:09:40 +12:00
Jesse Hills
dc26ed9c46 Bump version to 2025.7.3 2025-07-23 00:34:13 +12:00
Keith Burzinski
8674012406 [bme680_bsec] Add suggested alternate when using IDF (#9785) 2025-07-23 00:34:12 +12:00
Keith Burzinski
ae12deff87 [neopixelbus] Add suggested alternate when using IDF (#9783) 2025-07-23 00:34:12 +12:00
Keith Burzinski
cb6acfe24b [fastled_clockless, fastled_spi] Add suggested alternate when using IDF (#9784) 2025-07-23 00:34:12 +12:00
J. Nick Koston
fc8c5a7438 [core] Process pending loop enables during setup blocking phase (#9787) 2025-07-23 00:34:06 +12:00
Keith Burzinski
f8777d3b66 [config_validation] Add support for suggesting alternate component/platform (#9757)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-07-23 00:30:36 +12:00
Jesse Hills
76e75f4cdc [tuya] Update use of fan_schema (#9762) 2025-07-23 00:29:40 +12:00
Jonathan Swoboda
896d7f8f76 [esp32_touch] Fix setup mode in v1 driver (#9725) 2025-07-23 00:29:40 +12:00
JonasB2497
d92ee563f2 [sdl][mipi_spi] Respect clipping when drawing (#9722)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2025-07-23 00:29:34 +12:00
tmpeh
d6ff790823 Fix format string error in ota_web_server.cpp (#9711) 2025-07-23 00:25:51 +12:00
J. Nick Koston
7ac60c15dc [gpio] Auto-disable interrupts for shared GPIO pins in binary sensors (#9701) 2025-07-23 00:25:51 +12:00
Jesse Hills
6fe4ffa0cf Merge pull request #9691 from esphome/bump-2025.7.2
2025.7.2
2025-07-19 12:04:51 +12:00
Jesse Hills
576ce7ee35 Bump version to 2025.7.2 2025-07-19 09:56:08 +12:00
J. Nick Koston
8a45e877bb [gpio] Disable interrupt mode by default for LibreTiny platforms (#9687)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-19 09:56:08 +12:00
Kevin Ahrendt
84607c1255 [voice_assistant] Use media player callbacks to track TTS response status (#9670) 2025-07-19 09:56:01 +12:00
Kevin Ahrendt
8664ec0a3b [speaker] Media player's pipeline properly returns playing state near end of file (#9668) 2025-07-19 09:54:15 +12:00
J. Nick Koston
32d8c60a0b Fix AsyncTCP version mismatch between platformio.ini and async_tcp component (#9676) 2025-07-19 09:54:00 +12:00
Jesse Hills
976a1e27b4 [lvgl] Prevent keyerror on min/max value widgets with no default (#9660) 2025-07-19 09:53:47 +12:00
J. Nick Koston
cc2c1b1d89 [libretiny] Remove unsupported lock-free queue and event pool implementations (#9653) 2025-07-19 09:53:47 +12:00
Clyde Stubbs
85495d38b7 [lvgl] Fix meter rotation (#9605)
Co-authored-by: clydeps <U5yx99dok9>
2025-07-19 09:53:47 +12:00
J. Nick Koston
84a77ee427 [scheduler] Fix DelayAction cancellation in restart mode scripts (#9646) 2025-07-19 09:53:47 +12:00
@RubenKelevra
11a4115e30 esp32_camera: deprecate i2c_pins; throw error if combined with i2c: block (#9615) 2025-07-19 09:53:47 +12:00
Samuel Sieb
121ed687f3 [logger] fix on_message (#9642)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-07-19 09:53:47 +12:00
J. Nick Koston
c602f3082e [scheduler] Fix cancellation of timers with empty string names (#9641) 2025-07-19 09:53:39 +12:00
J. Nick Koston
4a43f922c6 [wireguard] Fix boot loop when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled (#9637) 2025-07-19 09:50:36 +12:00
J. Nick Koston
21e66b76e4 [api] Fix compilation error with char* lambdas in HomeAssistant services (#9638)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-19 09:50:36 +12:00
Flo
cdeed7afa7 Fix template event web_server crash (#9618) 2025-07-19 09:50:36 +12:00
Jesse Hills
1a9f02fa63 Merge pull request #9596 from esphome/bump-2025.7.1
2025.7.1
2025-07-17 21:54:35 +12:00
Jesse Hills
7ad1b039f9 Bump version to 2025.7.1 2025-07-17 19:40:03 +12:00
J. Nick Koston
e255d73c29 Fix lwIP thread safety assertion failures on ESP32 (#9570) 2025-07-17 19:39:57 +12:00
Jesse Hills
46f5c44b37 [esp32] Add missing include for helpers (#9579)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-07-17 19:39:39 +12:00
J. Nick Koston
9d80889bc9 Allow disabling OTA for web_server while keeping it enabled for captive_portal (#9583) 2025-07-17 19:39:39 +12:00
J. Nick Koston
08a5ba6ef1 Add helpful error message when ESP32+Arduino runs out of flash space (#9580) 2025-07-17 19:39:39 +12:00
J. Nick Koston
28128c65e5 Fix format string warnings in Web Server OTA component (#9569)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-07-17 19:39:39 +12:00
J. Nick Koston
efcad565ee Fix compilation error when using string lambdas with homeassistant services (#9543) 2025-07-17 19:39:39 +12:00
Vladimir Kuznetsov
cd987feb5b [lvgl]: fix missing await keyword in meter tick_style width processing (#9538) 2025-07-17 19:39:12 +12:00
Jesse Hills
5707389faa Merge pull request #9534 from esphome/bump-2025.7.0
2025.7.0
2025-07-16 20:46:26 +12:00
Jesse Hills
3f78db5c63 Bump version to 2025.7.0 2025-07-16 12:31:13 +12:00
64 changed files with 1123 additions and 245 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.0b5 PROJECT_NUMBER = 2025.7.5
# 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

@@ -657,10 +657,16 @@ class APIConnection : public APIServerConnection {
bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type,
uint8_t estimated_size) { uint8_t estimated_size) {
// Try to send immediately if: // Try to send immediately if:
// 1. We should try to send immediately (should_try_send_immediately = true) // 1. It's an UpdateStateResponse (always send immediately to handle cases where
// 2. Batch delay is 0 (user has opted in to immediate sending) // the main loop is blocked, e.g., during OTA updates)
// 3. Buffer has space available // 2. OR: We should try to send immediately (should_try_send_immediately = true)
if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && // AND Batch delay is 0 (user has opted in to immediate sending)
// 3. AND: Buffer has space available
if ((
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
#endif
(this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) &&
this->helper_->can_write_without_blocking()) { this->helper_->can_write_without_blocking()) {
// Now actually encode and send // Now actually encode and send
if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) &&

View File

@@ -11,6 +11,18 @@ namespace esphome {
namespace api { namespace api {
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> { 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: public:
TemplatableStringValue() : TemplatableValue<std::string, X...>() {} 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> template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
TemplatableStringValue(F f) 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 { template<typename... Ts> class TemplatableKeyValuePair {

View File

@@ -1,7 +1,7 @@
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import esp32, i2c from esphome.components import esp32, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework
CODEOWNERS = ["@trvrnrth"] CODEOWNERS = ["@trvrnrth"]
DEPENDENCIES = ["i2c"] DEPENDENCIES = ["i2c"]
@@ -56,7 +56,15 @@ CONFIG_SCHEMA = cv.All(
): cv.positive_time_period_minutes, ): cv.positive_time_period_minutes,
} }
).extend(i2c.i2c_device_schema(0x76)), ).extend(i2c.i2c_device_schema(0x76)),
cv.only_with_arduino, cv.only_with_framework(
frameworks=Framework.ARDUINO,
suggestions={
Framework.ESP_IDF: (
"bme68x_bsec2_i2c",
"sensor/bme68x_bsec2",
)
},
),
cv.Any( cv.Any(
cv.only_on_esp8266, cv.only_on_esp8266,
cv.All( cv.All(

View File

@@ -4,6 +4,7 @@
#include "esphome/components/network/ip_address.h" #include "esphome/components/network/ip_address.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/util.h" #include "esphome/core/util.h"
#include "esphome/core/helpers.h"
#include <lwip/igmp.h> #include <lwip/igmp.h>
#include <lwip/init.h> #include <lwip/init.h>
@@ -71,7 +72,11 @@ bool E131Component::join_igmp_groups_() {
ip4_addr_t multicast_addr = ip4_addr_t multicast_addr =
network::IPAddress(239, 255, ((universe.first >> 8) & 0xff), ((universe.first >> 0) & 0xff)); 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) { if (err) {
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe.first); 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) { if (listen_method_ == E131_MULTICAST) {
ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff));
LwIPLock lock;
igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr); igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr);
} }

View File

@@ -1,4 +1,5 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32 #ifdef USE_ESP32
@@ -30,6 +31,45 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_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) void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED) #if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
// When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default

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

@@ -16,6 +16,8 @@ namespace esp32_touch {
static const char *const TAG = "esp32_touch"; static const char *const TAG = "esp32_touch";
static const uint32_t SETUP_MODE_THRESHOLD = 0xFFFF;
void ESP32TouchComponent::setup() { void ESP32TouchComponent::setup() {
// Create queue for touch events // Create queue for touch events
// Queue size calculation: children * 4 allows for burst scenarios where ISR // Queue size calculation: children * 4 allows for burst scenarios where ISR
@@ -44,8 +46,12 @@ void ESP32TouchComponent::setup() {
// Configure each touch pad // Configure each touch pad
for (auto *child : this->children_) { for (auto *child : this->children_) {
if (this->setup_mode_) {
touch_pad_config(child->get_touch_pad(), SETUP_MODE_THRESHOLD);
} else {
touch_pad_config(child->get_touch_pad(), child->get_threshold()); touch_pad_config(child->get_touch_pad(), child->get_threshold());
} }
}
// Register ISR handler // Register ISR handler
esp_err_t err = touch_pad_isr_register(touch_isr_handler, this); esp_err_t err = touch_pad_isr_register(touch_isr_handler, this);
@@ -114,8 +120,8 @@ void ESP32TouchComponent::loop() {
child->publish_state(new_state); child->publish_state(new_state);
// Original ESP32: ISR only fires when touched, release is detected by timeout // Original ESP32: ISR only fires when touched, release is detected by timeout
// Note: ESP32 v1 uses inverted logic - touched when value < threshold // Note: ESP32 v1 uses inverted logic - touched when value < threshold
ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 " < threshold: %" PRIu32 ")", ESP_LOGV(TAG, "Touch Pad '%s' state: %s (value: %" PRIu32 " < threshold: %" PRIu32 ")",
child->get_name().c_str(), event.value, child->get_threshold()); child->get_name().c_str(), ONOFF(new_state), event.value, child->get_threshold());
} }
break; // Exit inner loop after processing matching pad break; // Exit inner loop after processing matching pad
} }
@@ -188,11 +194,6 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) {
// as any pad remains touched. This allows us to detect both new touches and // as any pad remains touched. This allows us to detect both new touches and
// continued touches, but releases must be detected by timeout in the main loop. // continued touches, but releases must be detected by timeout in the main loop.
// IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2!
// ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE
// Therefore: touched = (value < threshold)
// This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold)
// Process all configured pads to check their current state // Process all configured pads to check their current state
// Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt, // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt,
// so we must scan all configured pads to find which ones were touched // so we must scan all configured pads to find which ones were touched
@@ -211,11 +212,16 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) {
} }
// Skip pads that arent in the trigger mask // Skip pads that arent in the trigger mask
bool is_touched = (mask >> pad) & 1; if (((mask >> pad) & 1) == 0) {
if (!is_touched) {
continue; continue;
} }
// IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2!
// ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE
// Therefore: touched = (value < threshold)
// This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold)
bool is_touched = value < child->get_threshold();
// Always send the current state - the main loop will filter for changes // Always send the current state - the main loop will filter for changes
// We send both touched and untouched states because the ISR doesn't // We send both touched and untouched states because the ISR doesn't
// track previous state (to keep ISR fast and simple) // track previous state (to keep ISR fast and simple)

View File

@@ -22,6 +22,10 @@ void Mutex::unlock() {}
IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); }
IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } 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) void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
wifi_get_macaddr(STATION_IF, mac); wifi_get_macaddr(STATION_IF, mac);
} }

View File

@@ -420,6 +420,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() {
} }
network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { network::IPAddress EthernetComponent::get_dns_address(uint8_t num) {
LwIPLock lock;
const ip_addr_t *dns_ip = dns_getserver(num); const ip_addr_t *dns_ip = dns_getserver(num);
return dns_ip; return dns_ip;
} }
@@ -527,6 +528,7 @@ void EthernetComponent::start_connect_() {
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
if (this->manual_ip_.has_value()) { if (this->manual_ip_.has_value()) {
LwIPLock lock;
if (this->manual_ip_->dns1.is_set()) { if (this->manual_ip_->dns1.is_set()) {
ip_addr_t d; ip_addr_t d;
d = this->manual_ip_->dns1; d = this->manual_ip_->dns1;
@@ -559,8 +561,13 @@ bool EthernetComponent::is_connected() { return this->state_ == EthernetComponen
void EthernetComponent::dump_connect_params_() { void EthernetComponent::dump_connect_params_() {
esp_netif_ip_info_t ip; esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip); esp_netif_get_ip_info(this->eth_netif_, &ip);
const ip_addr_t *dns_ip1 = dns_getserver(0); const ip_addr_t *dns_ip1;
const ip_addr_t *dns_ip2 = dns_getserver(1); const ip_addr_t *dns_ip2;
{
LwIPLock lock;
dns_ip1 = dns_getserver(0);
dns_ip2 = dns_getserver(1);
}
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
" IP Address: %s\n" " IP Address: %s\n"

View File

@@ -2,7 +2,13 @@ from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import fastled_base from esphome.components import fastled_base
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_CHIPSET, CONF_NUM_LEDS, CONF_PIN, CONF_RGB_ORDER from esphome.const import (
CONF_CHIPSET,
CONF_NUM_LEDS,
CONF_PIN,
CONF_RGB_ORDER,
Framework,
)
AUTO_LOAD = ["fastled_base"] AUTO_LOAD = ["fastled_base"]
@@ -48,13 +54,22 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
} }
), ),
_validate, cv.only_with_framework(
frameworks=Framework.ARDUINO,
suggestions={
Framework.ESP_IDF: (
"esp32_rmt_led_strip",
"light/esp32_rmt_led_strip",
)
},
),
cv.require_framework_version( cv.require_framework_version(
esp8266_arduino=cv.Version(2, 7, 4), esp8266_arduino=cv.Version(2, 7, 4),
esp32_arduino=cv.Version(99, 0, 0), esp32_arduino=cv.Version(99, 0, 0),
max_version=True, max_version=True,
extra_message="Please see note on documentation for FastLED", extra_message="Please see note on documentation for FastLED",
), ),
_validate,
) )

View File

@@ -9,6 +9,7 @@ from esphome.const import (
CONF_DATA_RATE, CONF_DATA_RATE,
CONF_NUM_LEDS, CONF_NUM_LEDS,
CONF_RGB_ORDER, CONF_RGB_ORDER,
Framework,
) )
AUTO_LOAD = ["fastled_base"] AUTO_LOAD = ["fastled_base"]
@@ -33,6 +34,15 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_DATA_RATE): cv.frequency, cv.Optional(CONF_DATA_RATE): cv.frequency,
} }
), ),
cv.only_with_framework(
frameworks=Framework.ARDUINO,
suggestions={
Framework.ESP_IDF: (
"spi_led_strip",
"light/spi_led_strip",
)
},
),
cv.require_framework_version( cv.require_framework_version(
esp8266_arduino=cv.Version(2, 7, 4), esp8266_arduino=cv.Version(2, 7, 4),
esp32_arduino=cv.Version(99, 0, 0), esp32_arduino=cv.Version(99, 0, 0),

View File

@@ -15,6 +15,7 @@ from freetype import (
FT_LOAD_RENDER, FT_LOAD_RENDER,
FT_LOAD_TARGET_MONO, FT_LOAD_TARGET_MONO,
Face, Face,
FT_Exception,
ft_pixel_mode_mono, ft_pixel_mode_mono,
) )
import requests import requests
@@ -94,7 +95,14 @@ class FontCache(MutableMapping):
return self.store[self._keytransform(item)] return self.store[self._keytransform(item)]
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.store[self._keytransform(key)] = Face(str(value)) transformed = self._keytransform(key)
try:
self.store[transformed] = Face(str(value))
except FT_Exception as exc:
file = transformed.split(":", 1)
raise cv.Invalid(
f"{file[0].capitalize()} {file[1]} is not a valid font file"
) from exc
FONT_CACHE = FontCache() FONT_CACHE = FontCache()

View File

@@ -4,7 +4,13 @@ from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import binary_sensor from esphome.components import binary_sensor
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME, CONF_NUMBER, CONF_PIN from esphome.const import (
CONF_ALLOW_OTHER_USES,
CONF_ID,
CONF_NAME,
CONF_NUMBER,
CONF_PIN,
)
from esphome.core import CORE from esphome.core import CORE
from .. import gpio_ns from .. import gpio_ns
@@ -29,7 +35,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
), ),
@@ -62,6 +82,18 @@ async def to_code(config):
) )
use_interrupt = False use_interrupt = False
# Check if pin is shared with other components (allow_other_uses)
# When a pin is shared, interrupts can interfere with other components
# (e.g., duty_cycle sensor) that need to monitor the pin's state changes
if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False):
_LOGGER.info(
"GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. "
"The sensor will use polling mode for compatibility with other pin uses.",
config.get(CONF_NAME, config[CONF_ID]),
config[CONF_PIN][CONF_NUMBER],
)
use_interrupt = False
cg.add(var.set_use_interrupt(use_interrupt)) cg.add(var.set_use_interrupt(use_interrupt))
if use_interrupt: if use_interrupt:
cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE]))

View File

@@ -8,6 +8,8 @@ namespace gt911 {
static const char *const TAG = "gt911.touchscreen"; static const char *const TAG = "gt911.touchscreen";
static const uint8_t PRIMARY_ADDRESS = 0x5D; // default I2C address for GT911
static const uint8_t SECONDARY_ADDRESS = 0x14; // secondary I2C address for GT911
static const uint8_t GET_TOUCH_STATE[2] = {0x81, 0x4E}; static const uint8_t GET_TOUCH_STATE[2] = {0x81, 0x4E};
static const uint8_t CLEAR_TOUCH_STATE[3] = {0x81, 0x4E, 0x00}; static const uint8_t CLEAR_TOUCH_STATE[3] = {0x81, 0x4E, 0x00};
static const uint8_t GET_TOUCHES[2] = {0x81, 0x4F}; static const uint8_t GET_TOUCHES[2] = {0x81, 0x4F};
@@ -18,8 +20,7 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned
#define ERROR_CHECK(err) \ #define ERROR_CHECK(err) \
if ((err) != i2c::ERROR_OK) { \ if ((err) != i2c::ERROR_OK) { \
ESP_LOGE(TAG, "Failed to communicate!"); \ this->status_set_warning("Communication failure"); \
this->status_set_warning(); \
return; \ return; \
} }
@@ -30,31 +31,31 @@ void GT911Touchscreen::setup() {
this->reset_pin_->setup(); this->reset_pin_->setup();
this->reset_pin_->digital_write(false); this->reset_pin_->digital_write(false);
if (this->interrupt_pin_ != nullptr) { if (this->interrupt_pin_ != nullptr) {
// The interrupt pin is used as an input during reset to select the I2C address. // temporarily set the interrupt pin to output to control address selection
this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT);
this->interrupt_pin_->setup();
this->interrupt_pin_->digital_write(false); this->interrupt_pin_->digital_write(false);
} }
delay(2); delay(2);
this->reset_pin_->digital_write(true); this->reset_pin_->digital_write(true);
delay(50); // NOLINT delay(50); // NOLINT
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
this->interrupt_pin_->setup();
} }
if (this->interrupt_pin_ != nullptr) {
// set pre-configured input mode
this->interrupt_pin_->setup();
} }
// check the configuration of the int line. // check the configuration of the int line.
uint8_t data[4]; uint8_t data[4];
err = this->write(GET_SWITCHES, 2); err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES));
if (err != i2c::ERROR_OK && this->address_ == PRIMARY_ADDRESS) {
this->address_ = SECONDARY_ADDRESS;
err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES));
}
if (err == i2c::ERROR_OK) { if (err == i2c::ERROR_OK) {
err = this->read(data, 1); err = this->read(data, 1);
if (err == i2c::ERROR_OK) { if (err == i2c::ERROR_OK) {
ESP_LOGD(TAG, "Read from switches: 0x%02X", data[0]); ESP_LOGD(TAG, "Read from switches at address 0x%02X: 0x%02X", this->address_, data[0]);
if (this->interrupt_pin_ != nullptr) { if (this->interrupt_pin_ != nullptr) {
// datasheet says NOT to use pullup/down on the int line.
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, this->attach_interrupt_(this->interrupt_pin_,
(data[0] & 1) ? gpio::INTERRUPT_FALLING_EDGE : gpio::INTERRUPT_RISING_EDGE); (data[0] & 1) ? gpio::INTERRUPT_FALLING_EDGE : gpio::INTERRUPT_RISING_EDGE);
} }
@@ -63,7 +64,7 @@ void GT911Touchscreen::setup() {
if (this->x_raw_max_ == 0 || this->y_raw_max_ == 0) { if (this->x_raw_max_ == 0 || this->y_raw_max_ == 0) {
// no calibration? Attempt to read the max values from the touchscreen. // no calibration? Attempt to read the max values from the touchscreen.
if (err == i2c::ERROR_OK) { if (err == i2c::ERROR_OK) {
err = this->write(GET_MAX_VALUES, 2); err = this->write(GET_MAX_VALUES, sizeof(GET_MAX_VALUES));
if (err == i2c::ERROR_OK) { if (err == i2c::ERROR_OK) {
err = this->read(data, sizeof(data)); err = this->read(data, sizeof(data));
if (err == i2c::ERROR_OK) { if (err == i2c::ERROR_OK) {
@@ -75,15 +76,12 @@ void GT911Touchscreen::setup() {
} }
} }
if (err != i2c::ERROR_OK) { if (err != i2c::ERROR_OK) {
ESP_LOGE(TAG, "Failed to read calibration values from touchscreen!"); this->mark_failed("Failed to read calibration");
this->mark_failed();
return; return;
} }
} }
if (err != i2c::ERROR_OK) { if (err != i2c::ERROR_OK) {
ESP_LOGE(TAG, "Failed to communicate!"); this->mark_failed("Failed to communicate");
this->mark_failed();
return;
} }
ESP_LOGCONFIG(TAG, "GT911 Touchscreen setup complete"); ESP_LOGCONFIG(TAG, "GT911 Touchscreen setup complete");
@@ -94,7 +92,7 @@ void GT911Touchscreen::update_touches() {
uint8_t touch_state = 0; uint8_t touch_state = 0;
uint8_t data[MAX_TOUCHES + 1][8]; // 8 bytes each for each point, plus extra space for the key byte uint8_t data[MAX_TOUCHES + 1][8]; // 8 bytes each for each point, plus extra space for the key byte
err = this->write(GET_TOUCH_STATE, sizeof(GET_TOUCH_STATE), false); err = this->write(GET_TOUCH_STATE, sizeof(GET_TOUCH_STATE));
ERROR_CHECK(err); ERROR_CHECK(err);
err = this->read(&touch_state, 1); err = this->read(&touch_state, 1);
ERROR_CHECK(err); ERROR_CHECK(err);
@@ -106,7 +104,7 @@ void GT911Touchscreen::update_touches() {
return; return;
} }
err = this->write(GET_TOUCHES, sizeof(GET_TOUCHES), false); err = this->write(GET_TOUCHES, sizeof(GET_TOUCHES));
ERROR_CHECK(err); ERROR_CHECK(err);
// num_of_touches is guaranteed to be 0..5. Also read the key data // num_of_touches is guaranteed to be 0..5. Also read the key data
err = this->read(data[0], sizeof(data[0]) * num_of_touches + 1); err = this->read(data[0], sizeof(data[0]) * num_of_touches + 1);
@@ -132,6 +130,7 @@ void GT911Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "GT911 Touchscreen:"); ESP_LOGCONFIG(TAG, "GT911 Touchscreen:");
LOG_I2C_DEVICE(this); LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
} }
} // namespace gt911 } // namespace gt911

View File

@@ -24,9 +24,6 @@ static const uint32_t READ_DURATION_MS = 16;
static const size_t TASK_STACK_SIZE = 4096; static const size_t TASK_STACK_SIZE = 4096;
static const ssize_t TASK_PRIORITY = 23; static const ssize_t TASK_PRIORITY = 23;
// Use an exponential moving average to correct a DC offset with weight factor 1/1000
static const int32_t DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR = 1000;
static const char *const TAG = "i2s_audio.microphone"; static const char *const TAG = "i2s_audio.microphone";
enum MicrophoneEventGroupBits : uint32_t { enum MicrophoneEventGroupBits : uint32_t {
@@ -382,26 +379,57 @@ void I2SAudioMicrophone::mic_task(void *params) {
} }
void I2SAudioMicrophone::fix_dc_offset_(std::vector<uint8_t> &data) { void I2SAudioMicrophone::fix_dc_offset_(std::vector<uint8_t> &data) {
/**
* From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html:
*
* y(n) = x(n) - x(n-1) + R * y(n-1)
* R = 1 - (pi * 2 * frequency / samplerate)
*
* From https://en.wikipedia.org/wiki/Hearing_range:
* The human range is commonly given as 20Hz up.
*
* From https://en.wikipedia.org/wiki/High-resolution_audio:
* A reasonable upper bound for sample rate seems to be 96kHz.
*
* Calculate R value for 20Hz on a 96kHz sample rate:
* R = 1 - (pi * 2 * 20 / 96000)
* R = 0.9986910031
*
* Transform floating point to bit-shifting approximation:
* output = input - prev_input + R * prev_output
* output = input - prev_input + (prev_output - (prev_output >> S))
*
* Approximate bit-shift value S from R:
* R = 1 - (1 >> S)
* R = 1 - (1 / 2^S)
* R = 1 - 2^-S
* 0.9986910031 = 1 - 2^-S
* S = 9.57732 ~= 10
*
* Actual R from S:
* R = 1 - 2^-10 = 0.9990234375
*
* Confirm this has effect outside human hearing on 96000kHz sample:
* 0.9990234375 = 1 - (pi * 2 * f / 96000)
* f = 14.9208Hz
*
* Confirm this has effect outside human hearing on PDM 16kHz sample:
* 0.9990234375 = 1 - (pi * 2 * f / 16000)
* f = 2.4868Hz
*
*/
const uint8_t dc_filter_shift = 10;
const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1);
const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size());
if (total_samples == 0) {
return;
}
int64_t offset_accumulator = 0;
for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) {
const uint32_t byte_index = sample_index * bytes_per_sample; const uint32_t byte_index = sample_index * bytes_per_sample;
int32_t sample = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample);
offset_accumulator += sample; int32_t output = input - this->dc_offset_prev_input_ +
sample -= this->dc_offset_; (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift));
audio::pack_q31_as_audio_sample(sample, &data[byte_index], bytes_per_sample); this->dc_offset_prev_input_ = input;
this->dc_offset_prev_output_ = output;
audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample);
} }
const int32_t new_offset = offset_accumulator / total_samples;
this->dc_offset_ = new_offset / DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR +
(DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR - 1) * this->dc_offset_ /
DC_OFFSET_MOVING_AVERAGE_COEFFICIENT_DENOMINATOR;
} }
size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) {

View File

@@ -82,7 +82,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub
bool correct_dc_offset_; bool correct_dc_offset_;
bool locked_driver_{false}; bool locked_driver_{false};
int32_t dc_offset_{0}; int32_t dc_offset_prev_input_{0};
int32_t dc_offset_prev_output_{0};
}; };
} // namespace i2s_audio } // namespace i2s_audio

View File

@@ -477,10 +477,11 @@ void LD2450Component::handle_periodic_data_() {
// X // X
start = TARGET_X + index * 8; start = TARGET_X + index * 8;
is_moving = false; is_moving = false;
sensor::Sensor *sx = this->move_x_sensors_[index]; // tx is used for further calculations, so always needs to be populated
if (sx != nullptr) {
val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]);
tx = val; tx = val;
sensor::Sensor *sx = this->move_x_sensors_[index];
if (sx != nullptr) {
if (this->cached_target_data_[index].x != val) { if (this->cached_target_data_[index].x != val) {
sx->publish_state(val); sx->publish_state(val);
this->cached_target_data_[index].x = val; this->cached_target_data_[index].x = val;
@@ -488,10 +489,11 @@ void LD2450Component::handle_periodic_data_() {
} }
// Y // Y
start = TARGET_Y + index * 8; start = TARGET_Y + index * 8;
sensor::Sensor *sy = this->move_y_sensors_[index]; // ty is used for further calculations, so always needs to be populated
if (sy != nullptr) {
val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); val = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]);
ty = val; ty = val;
sensor::Sensor *sy = this->move_y_sensors_[index];
if (sy != nullptr) {
if (this->cached_target_data_[index].y != val) { if (this->cached_target_data_[index].y != val) {
sy->publish_state(val); sy->publish_state(val);
this->cached_target_data_[index].y = val; this->cached_target_data_[index].y = val;

View File

@@ -26,6 +26,10 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_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) void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
WiFi.macAddress(mac); WiFi.macAddress(mac);
} }

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"),
], ],
@@ -400,6 +400,7 @@ CONF_LOGGER_LOG = "logger.log"
LOGGER_LOG_ACTION_SCHEMA = cv.All( LOGGER_LOG_ACTION_SCHEMA = cv.All(
cv.maybe_simple_value( cv.maybe_simple_value(
{ {
cv.GenerateID(CONF_LOGGER_ID): cv.use_id(Logger),
cv.Required(CONF_FORMAT): cv.string, cv.Required(CONF_FORMAT): cv.string,
cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_), cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_),
cv.Optional(CONF_LEVEL, default="DEBUG"): cv.one_of( cv.Optional(CONF_LEVEL, default="DEBUG"): cv.one_of(

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:
@@ -264,7 +267,7 @@ class MeterType(WidgetType):
color_start, color_start,
color_end, color_end,
v[CONF_LOCAL], v[CONF_LOCAL],
size.process(v[CONF_WIDTH]), await size.process(v[CONF_WIDTH]),
), ),
) )
if t == CONF_IMAGE: if t == CONF_IMAGE:

View File

@@ -15,7 +15,7 @@ from ..defines import (
TILE_DIRECTIONS, TILE_DIRECTIONS,
literal, literal,
) )
from ..lv_validation import animated, lv_int from ..lv_validation import animated, lv_int, lv_pct
from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable
from ..schemas import container_schema from ..schemas import container_schema
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
@@ -41,8 +41,8 @@ TILEVIEW_SCHEMA = cv.Schema(
container_schema( container_schema(
obj_spec, obj_spec,
{ {
cv.Required(CONF_ROW): lv_int, cv.Required(CONF_ROW): cv.positive_int,
cv.Required(CONF_COLUMN): lv_int, cv.Required(CONF_COLUMN): cv.positive_int,
cv.GenerateID(): cv.declare_id(lv_tile_t), cv.GenerateID(): cv.declare_id(lv_tile_t),
cv.Optional(CONF_DIR, default="ALL"): TILE_DIRECTIONS.several_of, cv.Optional(CONF_DIR, default="ALL"): TILE_DIRECTIONS.several_of,
}, },
@@ -63,21 +63,29 @@ class TileviewType(WidgetType):
) )
async def to_code(self, w: Widget, config: dict): async def to_code(self, w: Widget, config: dict):
for tile_conf in config.get(CONF_TILES, ()): tiles = config[CONF_TILES]
for tile_conf in tiles:
w_id = tile_conf[CONF_ID] w_id = tile_conf[CONF_ID]
tile_obj = lv_Pvariable(lv_obj_t, w_id) tile_obj = lv_Pvariable(lv_obj_t, w_id)
tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf) tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf)
dirs = tile_conf[CONF_DIR] dirs = tile_conf[CONF_DIR]
if isinstance(dirs, list): if isinstance(dirs, list):
dirs = "|".join(dirs) dirs = "|".join(dirs)
row_pos = tile_conf[CONF_ROW]
col_pos = tile_conf[CONF_COLUMN]
lv_assign( lv_assign(
tile_obj, tile_obj,
lv_expr.tileview_add_tile( lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, literal(dirs)),
w.obj, tile_conf[CONF_COLUMN], tile_conf[CONF_ROW], literal(dirs)
),
) )
# Bugfix for LVGL 8.x
lv_obj.set_pos(tile_obj, lv_pct(col_pos * 100), lv_pct(row_pos * 100))
await set_obj_properties(tile, tile_conf) await set_obj_properties(tile, tile_conf)
await add_widgets(tile, tile_conf) await add_widgets(tile, tile_conf)
if tiles:
# Set the first tile as active
lv_obj.set_tile_id(
w.obj, tiles[0][CONF_COLUMN], tiles[0][CONF_ROW], literal("LV_ANIM_OFF")
)
tileview_spec = TileviewType() tileview_spec = TileviewType()

View File

@@ -193,13 +193,17 @@ void MQTTClientComponent::start_dnslookup_() {
this->dns_resolve_error_ = false; this->dns_resolve_error_ = false;
this->dns_resolved_ = false; this->dns_resolved_ = false;
ip_addr_t addr; ip_addr_t addr;
err_t err;
{
LwIPLock lock;
#if USE_NETWORK_IPV6 #if USE_NETWORK_IPV6
err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV6_IPV4); this, LWIP_DNS_ADDRTYPE_IPV6_IPV4);
#else #else
err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV4); this, LWIP_DNS_ADDRTYPE_IPV4);
#endif /* USE_NETWORK_IPV6 */ #endif /* USE_NETWORK_IPV6 */
}
switch (err) { switch (err) {
case ERR_OK: { case ERR_OK: {
// Got IP immediately // Got IP immediately

View File

@@ -15,6 +15,7 @@ from esphome.const import (
CONF_PIN, CONF_PIN,
CONF_TYPE, CONF_TYPE,
CONF_VARIANT, CONF_VARIANT,
Framework,
) )
from esphome.core import CORE from esphome.core import CORE
@@ -162,7 +163,15 @@ def _validate_method(value):
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
cv.only_with_arduino, cv.only_with_framework(
frameworks=Framework.ARDUINO,
suggestions={
Framework.ESP_IDF: (
"esp32_rmt_led_strip",
"light/esp32_rmt_led_strip",
)
},
),
cv.require_framework_version( cv.require_framework_version(
esp8266_arduino=cv.Version(2, 4, 0), esp8266_arduino=cv.Version(2, 4, 0),
esp32_arduino=cv.Version(0, 0, 0), esp32_arduino=cv.Version(0, 0, 0),

View File

@@ -60,6 +60,20 @@ RemoteReceiverComponent = remote_receiver_ns.class_(
) )
def validate_config(config):
if CORE.is_esp32:
variant = esp32.get_esp32_variant()
if variant in (esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S2):
max_idle = 65535
else:
max_idle = 32767
if CONF_CLOCK_RESOLUTION in config:
max_idle = int(max_idle * 1000000 / config[CONF_CLOCK_RESOLUTION])
if config[CONF_IDLE].total_microseconds > max_idle:
raise cv.Invalid(f"config 'idle' exceeds the maximum value of {max_idle}us")
return config
def validate_tolerance(value): def validate_tolerance(value):
if isinstance(value, dict): if isinstance(value, dict):
return TOLERANCE_SCHEMA(value) return TOLERANCE_SCHEMA(value)
@@ -136,7 +150,9 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.boolean, cv.boolean,
), ),
} }
).extend(cv.COMPONENT_SCHEMA) )
.extend(cv.COMPONENT_SCHEMA)
.add_extra(validate_config)
) )

View File

@@ -86,10 +86,9 @@ void RemoteReceiverComponent::setup() {
uint32_t event_size = sizeof(rmt_rx_done_event_data_t); uint32_t event_size = sizeof(rmt_rx_done_event_data_t);
uint32_t max_filter_ns = 255u * 1000 / (RMT_CLK_FREQ / 1000000); uint32_t max_filter_ns = 255u * 1000 / (RMT_CLK_FREQ / 1000000);
uint32_t max_idle_ns = 65535u * 1000;
memset(&this->store_.config, 0, sizeof(this->store_.config)); memset(&this->store_.config, 0, sizeof(this->store_.config));
this->store_.config.signal_range_min_ns = std::min(this->filter_us_ * 1000, max_filter_ns); this->store_.config.signal_range_min_ns = std::min(this->filter_us_ * 1000, max_filter_ns);
this->store_.config.signal_range_max_ns = std::min(this->idle_us_ * 1000, max_idle_ns); this->store_.config.signal_range_max_ns = this->idle_us_ * 1000;
this->store_.filter_symbols = this->filter_symbols_; this->store_.filter_symbols = this->filter_symbols_;
this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t);
this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_);

View File

@@ -44,6 +44,10 @@ void Mutex::unlock() {}
IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); } IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); }
IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } 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) void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
#ifdef USE_WIFI #ifdef USE_WIFI
WiFi.macAddress(mac); WiFi.macAddress(mac);

View File

@@ -48,6 +48,9 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *
} }
void Sdl::draw_pixel_at(int x, int y, Color color) { void Sdl::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
return;
SDL_Rect rect{x, y, 1, 1}; SDL_Rect rect{x, y, 1, 1};
auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB)); auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB));
SDL_UpdateTexture(this->texture_, &rect, &data, 2); SDL_UpdateTexture(this->texture_, &rect, &data, 2);

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;
if (!this->speaker_->is_running()) {
return AudioPipelineState::STOPPED; 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

@@ -1,7 +1,7 @@
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import fan from esphome.components import fan
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_OUTPUT_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT from esphome.const import CONF_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT
from .. import CONF_TUYA_ID, Tuya, tuya_ns from .. import CONF_TUYA_ID, Tuya, tuya_ns
@@ -14,9 +14,9 @@ CONF_DIRECTION_DATAPOINT = "direction_datapoint"
TuyaFan = tuya_ns.class_("TuyaFan", cg.Component, fan.Fan) TuyaFan = tuya_ns.class_("TuyaFan", cg.Component, fan.Fan)
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
fan.FAN_SCHEMA.extend( fan.fan_schema(TuyaFan)
.extend(
{ {
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(TuyaFan),
cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya), cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya),
cv.Optional(CONF_OSCILLATION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_OSCILLATION_DATAPOINT): cv.uint8_t,
cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t, cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t,
@@ -24,7 +24,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t,
cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256), cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256),
} }
).extend(cv.COMPONENT_SCHEMA), )
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT), cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT),
) )
@@ -32,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config): async def to_code(config):
parent = await cg.get_variable(config[CONF_TUYA_ID]) parent = await cg.get_variable(config[CONF_TUYA_ID])
var = cg.new_Pvariable(config[CONF_OUTPUT_ID], parent, config[CONF_SPEED_COUNT]) var = cg.new_Pvariable(config[CONF_ID], parent, config[CONF_SPEED_COUNT])
await cg.register_component(var, config) await cg.register_component(var, config)
await fan.register_fan(var, config) await fan.register_fan(var, config)

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;
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); 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

@@ -74,13 +74,14 @@ def validate_local(config: ConfigType) -> ConfigType:
return config return config
def validate_ota_removed(config: ConfigType) -> ConfigType: def validate_ota(config: ConfigType) -> ConfigType:
# Only raise error if OTA is explicitly enabled (True) # The OTA option only accepts False to explicitly disable OTA for web_server
# If it's False or not specified, we can safely ignore it # IMPORTANT: Setting ota: false ONLY affects the web_server component
if config.get(CONF_OTA): # 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( raise cv.Invalid(
f"The '{CONF_OTA}' option has been removed from 'web_server'. " f"The '{CONF_OTA}' option in 'web_server' only accepts 'false' to disable OTA. "
f"Please use the new OTA platform structure instead:\n\n" f"To enable OTA, please use the new OTA platform structure instead:\n\n"
f"ota:\n" f"ota:\n"
f" - platform: web_server\n\n" f" - platform: web_server\n\n"
f"See https://esphome.io/components/ota for more information." f"See https://esphome.io/components/ota for more information."
@@ -185,7 +186,7 @@ CONFIG_SCHEMA = cv.All(
web_server_base.WebServerBase web_server_base.WebServerBase
), ),
cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean, 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_LOG, default=True): cv.boolean,
cv.Optional(CONF_LOCAL): cv.boolean, cv.Optional(CONF_LOCAL): cv.boolean,
cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group), cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group),
@@ -203,7 +204,7 @@ CONFIG_SCHEMA = cv.All(
default_url, default_url,
validate_local, validate_local,
validate_sorting_groups, 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_css_url(config[CONF_CSS_URL]))
cg.add(var.set_js_url(config[CONF_JS_URL])) cg.add(var.set_js_url(config[CONF_JS_URL]))
# OTA is now handled by the web_server OTA platform # 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])) cg.add(var.set_expose_log(config[CONF_LOG]))
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")

View File

@@ -5,6 +5,10 @@
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_CAPTIVE_PORTAL
#include "esphome/components/captive_portal/captive_portal.h"
#endif
#ifdef USE_ARDUINO #ifdef USE_ARDUINO
#ifdef USE_ESP8266 #ifdef USE_ESP8266
#include <Updater.h> #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, void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
bool final) override; bool final) override;
bool canHandle(AsyncWebServerRequest *request) const 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) // NOLINTNEXTLINE(readability-identifier-naming)
@@ -57,7 +76,7 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
percentage = (this->ota_read_length_ * 100.0f) / request->contentLength(); percentage = (this->ota_read_length_ * 100.0f) / request->contentLength();
ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage); ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage);
} else { } else {
ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_); ESP_LOGD(TAG, "OTA in progress: %" PRIu32 " bytes read", this->ota_read_length_);
} }
#ifdef USE_OTA_STATE_CALLBACK #ifdef USE_OTA_STATE_CALLBACK
// Report progress - use call_deferred since we're in web server task // Report progress - use call_deferred since we're in web server task
@@ -152,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin
// Finalize // Finalize
if (final) { 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=%" PRIu32 ", contentLength=%zu", index, len,
this->ota_read_length_, request->contentLength()); this->ota_read_length_, request->contentLength());
// For Arduino framework, the Update library tracks expected size from firmware header // For Arduino framework, the Update library tracks expected size from firmware header

View File

@@ -268,10 +268,10 @@ std::string WebServer::get_config_json() {
return json::build_json([this](JsonObject root) { return json::build_json([this](JsonObject root) {
root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name();
root["comment"] = App.get_comment(); root["comment"] = App.get_comment();
#ifdef USE_WEBSERVER_OTA #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA)
root["ota"] = true; // web_server OTA platform is configured root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal
#else #else
root["ota"] = false; root["ota"] = true;
#endif #endif
root["log"] = this->expose_log_; root["log"] = this->expose_log_;
root["lang"] = "en"; root["lang"] = "en";
@@ -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

@@ -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 " 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>")); "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 " 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>")); "type=\"file\" name=\"update\"><input type=\"submit\" value=\"Update\"></form>"));
#endif #endif

View File

@@ -20,10 +20,6 @@
#include "lwip/dns.h" #include "lwip/dns.h"
#include "lwip/err.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/application.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
@@ -298,22 +294,13 @@ bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
// sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!) // sntp_servermode_dhcp lwip/sntp.c (Required to lock TCPIP core functionality!)
// https://github.com/esphome/issues/issues/6591 // https://github.com/esphome/issues/issues/6591
// https://github.com/espressif/arduino-esp32/issues/10526 // https://github.com/espressif/arduino-esp32/issues/10526
#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING {
if (!sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) { LwIPLock lock;
LOCK_TCPIP_CORE();
}
#endif
// lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // 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. // the built-in SNTP client has a memory leak in certain situations. Disable this feature.
// https://github.com/esphome/issues/issues/2299 // https://github.com/esphome/issues/issues/2299
sntp_servermode_dhcp(false); 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 // No manual IP is set; use DHCP client
if (dhcp_status != ESP_NETIF_DHCP_STARTED) { if (dhcp_status != ESP_NETIF_DHCP_STARTED) {

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();
{
LwIPLock lock;
this->wg_initialized_ = esp_wireguard_init(&(this->wg_config_), &(this->wg_ctx_)); 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");
{
LwIPLock lock;
this->wg_connected_ = esp_wireguard_connect(&(this->wg_ctx_)); 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");
{
LwIPLock lock;
esp_wireguard_disconnect(&(this->wg_ctx_)); esp_wireguard_disconnect(&(this->wg_ctx_));
}
this->wg_connected_ = ESP_FAIL; this->wg_connected_ = ESP_FAIL;
} }
} }

View File

@@ -73,6 +73,7 @@ from esphome.const import (
TYPE_GIT, TYPE_GIT,
TYPE_LOCAL, TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS, VALID_SUBSTITUTIONS_CHARACTERS,
Framework,
__version__ as ESPHOME_VERSION, __version__ as ESPHOME_VERSION,
) )
from esphome.core import ( from esphome.core import (
@@ -282,6 +283,38 @@ class FinalExternalInvalid(Invalid):
"""Represents an invalid value in the final validation phase where the path should not be prepended.""" """Represents an invalid value in the final validation phase where the path should not be prepended."""
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int
extra: str = ""
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, value: str) -> Version:
match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value)
if match is None:
raise ValueError(f"Not a valid version number {value}")
major = int(match[1])
minor = int(match[2])
patch = int(match[3])
extra = match[4] or ""
return Version(major=major, minor=minor, patch=patch, extra=extra)
@property
def is_beta(self) -> bool:
"""Check if this version is a beta version."""
return self.extra.startswith("b")
@property
def is_dev(self) -> bool:
"""Check if this version is a development version."""
return self.extra.startswith("dev")
def check_not_templatable(value): def check_not_templatable(value):
if isinstance(value, Lambda): if isinstance(value, Lambda):
raise Invalid("This option is not templatable!") raise Invalid("This option is not templatable!")
@@ -619,16 +652,35 @@ def only_on(platforms):
return validator_ return validator_
def only_with_framework(frameworks): def only_with_framework(
frameworks: Framework | str | list[Framework | str], suggestions=None
):
"""Validate that this option can only be specified on the given frameworks.""" """Validate that this option can only be specified on the given frameworks."""
if not isinstance(frameworks, list): if not isinstance(frameworks, list):
frameworks = [frameworks] frameworks = [frameworks]
frameworks = [Framework(framework) for framework in frameworks]
if suggestions is None:
suggestions = {}
version = Version.parse(ESPHOME_VERSION)
if version.is_beta:
docs_format = "https://beta.esphome.io/components/{path}"
elif version.is_dev:
docs_format = "https://next.esphome.io/components/{path}"
else:
docs_format = "https://esphome.io/components/{path}"
def validator_(obj): def validator_(obj):
if CORE.target_framework not in frameworks: if CORE.target_framework not in frameworks:
raise Invalid( err_str = f"This feature is only available with framework(s) {', '.join([framework.value for framework in frameworks])}"
f"This feature is only available with frameworks {frameworks}" if suggestion := suggestions.get(CORE.target_framework, None):
) (component, docs_path) = suggestion
err_str += f"\nPlease use '{component}'"
if docs_path:
err_str += f": {docs_format.format(path=docs_path)}"
raise Invalid(err_str)
return obj return obj
return validator_ return validator_
@@ -637,8 +689,8 @@ def only_with_framework(frameworks):
only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp32 = only_on(PLATFORM_ESP32)
only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_esp8266 = only_on(PLATFORM_ESP8266)
only_on_rp2040 = only_on(PLATFORM_RP2040) only_on_rp2040 = only_on(PLATFORM_RP2040)
only_with_arduino = only_with_framework("arduino") only_with_arduino = only_with_framework(Framework.ARDUINO)
only_with_esp_idf = only_with_framework("esp-idf") only_with_esp_idf = only_with_framework(Framework.ESP_IDF)
# Adapted from: # Adapted from:
@@ -1965,26 +2017,6 @@ def source_refresh(value: str):
return positive_time_period_seconds(value) return positive_time_period_seconds(value)
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, value: str) -> Version:
match = re.match(r"^(\d+).(\d+).(\d+)-?\w*$", value)
if match is None:
raise ValueError(f"Not a valid version number {value}")
major = int(match[1])
minor = int(match[2])
patch = int(match[3])
return Version(major=major, minor=minor, patch=patch)
def version_number(value): def version_number(value):
value = string_strict(value) value = string_strict(value)
try: try:

View File

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

View File

@@ -68,8 +68,11 @@ void Application::setup() {
do { do {
uint8_t new_app_state = STATUS_LED_WARNING; uint8_t new_app_state = STATUS_LED_WARNING;
this->scheduler.call(); uint32_t now = millis();
this->feed_wdt();
// Process pending loop enables to handle GPIO interrupts during setup
this->before_loop_tasks_(now);
for (uint32_t j = 0; j <= i; j++) { for (uint32_t j = 0; j <= i; j++) {
// Update loop_component_start_time_ right before calling each component // Update loop_component_start_time_ right before calling each component
this->loop_component_start_time_ = millis(); this->loop_component_start_time_ = millis();
@@ -78,6 +81,8 @@ void Application::setup() {
this->app_state_ |= new_app_state; this->app_state_ |= new_app_state;
this->feed_wdt(); this->feed_wdt();
} }
this->after_loop_tasks_();
this->app_state_ = new_app_state; this->app_state_ = new_app_state;
yield(); yield();
} while (!component->can_proceed()); } while (!component->can_proceed());
@@ -94,30 +99,10 @@ void Application::setup() {
void Application::loop() { void Application::loop() {
uint8_t new_app_state = 0; uint8_t new_app_state = 0;
this->scheduler.call();
// Get the initial loop time at the start // Get the initial loop time at the start
uint32_t last_op_end_time = millis(); uint32_t last_op_end_time = millis();
// Feed WDT with time this->before_loop_tasks_(last_op_end_time);
this->feed_wdt(last_op_end_time);
// Process any pending enable_loop requests from ISRs
// This must be done before marking in_loop_ = true to avoid race conditions
if (this->has_pending_enable_loop_requests_) {
// Clear flag BEFORE processing to avoid race condition
// If ISR sets it during processing, we'll catch it next loop iteration
// This is safe because:
// 1. Each component has its own pending_enable_loop_ flag that we check
// 2. If we can't process a component (wrong state), enable_pending_loops_()
// will set this flag back to true
// 3. Any new ISR requests during processing will set the flag again
this->has_pending_enable_loop_requests_ = false;
this->enable_pending_loops_();
}
// Mark that we're in the loop for safe reentrant modifications
this->in_loop_ = true;
for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_; for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_;
this->current_loop_index_++) { this->current_loop_index_++) {
@@ -138,7 +123,7 @@ void Application::loop() {
this->feed_wdt(last_op_end_time); this->feed_wdt(last_op_end_time);
} }
this->in_loop_ = false; this->after_loop_tasks_();
this->app_state_ = new_app_state; this->app_state_ = new_app_state;
// Use the last component's end time instead of calling millis() again // Use the last component's end time instead of calling millis() again
@@ -400,6 +385,36 @@ void Application::enable_pending_loops_() {
} }
} }
void Application::before_loop_tasks_(uint32_t loop_start_time) {
// Process scheduled tasks
this->scheduler.call();
// Feed the watchdog timer
this->feed_wdt(loop_start_time);
// Process any pending enable_loop requests from ISRs
// This must be done before marking in_loop_ = true to avoid race conditions
if (this->has_pending_enable_loop_requests_) {
// Clear flag BEFORE processing to avoid race condition
// If ISR sets it during processing, we'll catch it next loop iteration
// This is safe because:
// 1. Each component has its own pending_enable_loop_ flag that we check
// 2. If we can't process a component (wrong state), enable_pending_loops_()
// will set this flag back to true
// 3. Any new ISR requests during processing will set the flag again
this->has_pending_enable_loop_requests_ = false;
this->enable_pending_loops_();
}
// Mark that we're in the loop for safe reentrant modifications
this->in_loop_ = true;
}
void Application::after_loop_tasks_() {
// Clear the in_loop_ flag to indicate we're done processing components
this->in_loop_ = false;
}
#ifdef USE_SOCKET_SELECT_SUPPORT #ifdef USE_SOCKET_SELECT_SUPPORT
bool Application::register_socket_fd(int fd) { bool Application::register_socket_fd(int fd) {
// WARNING: This function is NOT thread-safe and must only be called from the main loop // WARNING: This function is NOT thread-safe and must only be called from the main loop

View File

@@ -504,6 +504,8 @@ class Application {
void enable_component_loop_(Component *component); void enable_component_loop_(Component *component);
void enable_pending_loops_(); void enable_pending_loops_();
void activate_looping_component_(uint16_t index); void activate_looping_component_(uint16_t index);
void before_loop_tasks_(uint32_t loop_start_time);
void after_loop_tasks_();
void feed_wdt_arch_(); void feed_wdt_arch_();

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

@@ -67,7 +67,10 @@ To bit_cast(const From &src) {
return dst; return dst;
} }
#endif #endif
using std::lerp;
// clang-format off
inline float lerp(float completion, float start, float end) = delete; // Please use std::lerp. Notice that it has different order on arguments!
// clang-format on
// std::byteswap from C++23 // std::byteswap from C++23
template<typename T> constexpr T byteswap(T n) { template<typename T> constexpr T byteswap(T n) {
@@ -683,6 +686,23 @@ class InterruptLock {
#endif #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. /** 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 * Usually the ESPHome main loop runs at 60 Hz, sleeping in between invocations of `loop()` if necessary. When a higher

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

@@ -147,6 +147,13 @@ class RedirectText:
continue continue
self._write_color_replace(line) 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: else:
self._write_color_replace(s) self._write_color_replace(s)
@@ -309,3 +316,34 @@ def get_serial_ports() -> list[SerialPort]:
result.sort(key=lambda x: x.path) result.sort(key=lambda x: x.path)
return result 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"
)

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

@@ -6,7 +6,7 @@ set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
if [ ! -n "$VIRTUAL_ENV" ]; then if [ ! -n "$VIRTUAL_ENV" ]; then
if [ -x "$(command -v uv)" ]; then if [ -x "$(command -v uv)" ]; then
uv venv venv uv venv --seed venv
else else
python3 -m venv venv python3 -m venv venv
fi fi

View File

@@ -4,6 +4,8 @@ esphome:
esp32: esp32:
board: esp32dev board: esp32dev
logger:
text: text:
- platform: template - platform: template
name: "test 1 text" name: "test 1 text"

View File

@@ -8,31 +8,31 @@ from esphome.types import ConfigType
def test_web_server_ota_true_fails_validation() -> None: def test_web_server_ota_true_fails_validation() -> None:
"""Test that web_server with ota: true fails validation with helpful message.""" """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 with ota: true should fail
config: ConfigType = {"ota": True} config: ConfigType = {"ota": True}
with pytest.raises(cv.Invalid) as exc_info: with pytest.raises(cv.Invalid) as exc_info:
validate_ota_removed(config) validate_ota(config)
# Check error message contains migration instructions # Check error message contains migration instructions
error_msg = str(exc_info.value) 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 "platform: web_server" in error_msg
assert "ota:" in error_msg assert "ota:" in error_msg
def test_web_server_ota_false_passes_validation() -> None: def test_web_server_ota_false_passes_validation() -> None:
"""Test that web_server with ota: false passes validation.""" """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 with ota: false should pass
config: ConfigType = {"ota": False} config: ConfigType = {"ota": False}
result = validate_ota_removed(config) result = validate_ota(config)
assert result == config assert result == config
# Config without ota should also pass # Config without ota should also pass
config: ConfigType = {} config: ConfigType = {}
result = validate_ota_removed(config) result = validate_ota(config)
assert result == config assert result == config

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

@@ -738,7 +738,7 @@ lvgl:
id: bar_id id: bar_id
value: !lambda return (int)((float)rand() / RAND_MAX * 100); value: !lambda return (int)((float)rand() / RAND_MAX * 100);
start_value: !lambda return (int)((float)rand() / RAND_MAX * 100); start_value: !lambda return (int)((float)rand() / RAND_MAX * 100);
mode: symmetrical mode: range
- logger.log: - logger.log:
format: "bar value %f" format: "bar value %f"
args: [x] args: [x]
@@ -928,6 +928,12 @@ lvgl:
angle_range: 360 angle_range: 360
rotation: !lambda return 2700; rotation: !lambda return 2700;
indicators: indicators:
- tick_style:
start_value: 0
end_value: 60
color_start: 0x0000bd
color_end: 0xbd0000
width: !lambda return 1;
- line: - line:
opa: 50% opa: 50%
id: minute_hand id: minute_hand

View 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

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

@@ -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"
)

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