Merge branch 'dev' into mdns_mac_storage_reduce_ram

This commit is contained in:
J. Nick Koston
2025-11-24 10:43:15 -06:00
committed by GitHub
31 changed files with 706 additions and 67 deletions

View File

@@ -1319,7 +1319,7 @@ def parse_args(argv):
"clean-all", help="Clean all build and platform files."
)
parser_clean_all.add_argument(
"configuration", help="Your YAML configuration directory.", nargs="*"
"configuration", help="Your YAML file or configuration directory.", nargs="*"
)
parser_dashboard = subparsers.add_parser(

View File

@@ -1451,8 +1451,11 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
#ifdef USE_AREAS
resp.set_suggested_area(StringRef(App.get_area()));
#endif
// mac_address must store temporary string - will be valid during send_message call
std::string mac_address = get_mac_address_pretty();
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char mac_address[18];
uint8_t mac[6];
get_mac_address_raw(mac);
format_mac_addr_upper(mac, mac_address);
resp.set_mac_address(StringRef(mac_address));
resp.set_esphome_version(ESPHOME_VERSION_REF);
@@ -1493,8 +1496,9 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
#endif
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
// bt_mac must store temporary string - will be valid during send_message call
std::string bluetooth_mac = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty();
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char bluetooth_mac[18];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.set_bluetooth_mac_address(StringRef(bluetooth_mac));
#endif
#ifdef USE_VOICE_ASSISTANT

View File

@@ -239,12 +239,13 @@ APIError APINoiseFrameHelper::state_action_() {
}
if (state_ == State::SERVER_HELLO) {
// send server hello
constexpr size_t mac_len = 13; // 12 hex chars + null terminator
const std::string &name = App.get_name();
const std::string &mac = get_mac_address();
char mac[mac_len];
get_mac_address_into_buffer(mac);
// Calculate positions and sizes
size_t name_len = name.size() + 1; // including null terminator
size_t mac_len = mac.size() + 1; // including null terminator
size_t name_offset = 1;
size_t mac_offset = name_offset + name_len;
size_t total_size = 1 + name_len + mac_len;
@@ -257,7 +258,7 @@ APIError APINoiseFrameHelper::state_action_() {
// node name, terminated by null byte
std::memcpy(msg.get() + name_offset, name.c_str(), name_len);
// node mac, terminated by null byte
std::memcpy(msg.get() + mac_offset, mac.c_str(), mac_len);
std::memcpy(msg.get() + mac_offset, mac, mac_len);
aerr = write_frame_(msg.get(), total_size);
if (aerr != APIError::OK)

View File

@@ -130,11 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ
return flags;
}
std::string get_bluetooth_mac_address_pretty() {
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
const uint8_t *mac = esp_bt_dev_get_address();
char buf[18];
format_mac_addr_upper(mac, buf);
return std::string(buf);
format_mac_addr_upper(mac, output.data());
}
protected:

View File

@@ -883,6 +883,12 @@ async def to_code(config):
CORE.relative_internal_path(".espressif")
)
add_extra_script(
"pre",
"pre_build.py",
Path(__file__).parent / "pre_build.py.script",
)
add_extra_script(
"post",
"post_build.py",

View File

@@ -0,0 +1,9 @@
Import("env") # noqa: F821
# Remove custom_sdkconfig from the board config as it causes
# pioarduino to enable some strange hybrid build mode that breaks IDF
board = env.BoardConfig()
if "espidf.custom_sdkconfig" in board:
del board._manifest["espidf"]["custom_sdkconfig"]
if not board._manifest["espidf"]:
del board._manifest["espidf"]

View File

@@ -383,6 +383,7 @@ async def to_code(config):
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
if CONF_MANUAL_IP in config:
cg.add_define("USE_ETHERNET_MANUAL_IP")
cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP])))
# Add compile-time define for PHY types with specific code

View File

@@ -553,11 +553,14 @@ void EthernetComponent::start_connect_() {
}
esp_netif_ip_info_t info;
#ifdef USE_ETHERNET_MANUAL_IP
if (this->manual_ip_.has_value()) {
info.ip = this->manual_ip_->static_ip;
info.gw = this->manual_ip_->gateway;
info.netmask = this->manual_ip_->subnet;
} else {
} else
#endif
{
info.ip.addr = 0;
info.gw.addr = 0;
info.netmask.addr = 0;
@@ -578,6 +581,7 @@ void EthernetComponent::start_connect_() {
err = esp_netif_set_ip_info(this->eth_netif_, &info);
ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
#ifdef USE_ETHERNET_MANUAL_IP
if (this->manual_ip_.has_value()) {
LwIPLock lock;
if (this->manual_ip_->dns1.is_set()) {
@@ -590,7 +594,9 @@ void EthernetComponent::start_connect_() {
d = this->manual_ip_->dns2;
dns_setserver(1, &d);
}
} else {
} else
#endif
{
err = esp_netif_dhcpc_start(this->eth_netif_);
if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
ESPHL_ERROR_CHECK(err, "DHCPC start error");
@@ -688,7 +694,9 @@ void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->cl
void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
#endif
void EthernetComponent::set_type(EthernetType type) { this->type_ = type; }
#ifdef USE_ETHERNET_MANUAL_IP
void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; }
#endif
// set_use_address() is guaranteed to be called during component setup by Python code generation,
// so use_address_ will always be valid when get_use_address() is called - no fallback needed.

View File

@@ -82,7 +82,9 @@ class EthernetComponent : public Component {
void add_phy_register(PHYRegister register_value);
#endif
void set_type(EthernetType type);
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
#endif
void set_fixed_mac(const std::array<uint8_t, 6> &mac) { this->fixed_mac_ = mac; }
network::IPAddresses get_ip_addresses();
@@ -137,7 +139,9 @@ class EthernetComponent : public Component {
uint8_t mdc_pin_{23};
uint8_t mdio_pin_{18};
#endif
#ifdef USE_ETHERNET_MANUAL_IP
optional<ManualIP> manual_ip_{};
#endif
uint32_t connect_begin_;
// Group all uint8_t types together (enums and bools)

View File

@@ -23,6 +23,9 @@ void LightState::setup() {
effect->init_internal(this);
}
// Start with loop disabled if idle - respects any effects/transitions set up during initialization
this->disable_loop_if_idle_();
// When supported color temperature range is known, initialize color temperature setting within bounds.
auto traits = this->get_traits();
float min_mireds = traits.get_min_mireds();
@@ -125,6 +128,9 @@ void LightState::loop() {
this->is_transformer_active_ = false;
this->transformer_ = nullptr;
this->target_state_reached_callback_.call();
// Disable loop if idle (no transformer and no effect)
this->disable_loop_if_idle_();
}
}
@@ -132,6 +138,8 @@ void LightState::loop() {
if (this->next_write_) {
this->next_write_ = false;
this->output_->write_state(this);
// Disable loop if idle (no transformer and no effect)
this->disable_loop_if_idle_();
}
}
@@ -227,6 +235,8 @@ void LightState::start_effect_(uint32_t effect_index) {
this->active_effect_index_ = effect_index;
auto *effect = this->get_active_effect_();
effect->start_internal();
// Enable loop while effect is active
this->enable_loop();
}
LightEffect *LightState::get_active_effect_() {
if (this->active_effect_index_ == 0) {
@@ -241,6 +251,8 @@ void LightState::stop_effect_() {
effect->stop();
}
this->active_effect_index_ = 0;
// Disable loop if idle (no effect and no transformer)
this->disable_loop_if_idle_();
}
void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) {
@@ -250,6 +262,8 @@ void LightState::start_transition_(const LightColorValues &target, uint32_t leng
if (set_remote_values) {
this->remote_values = target;
}
// Enable loop while transition is active
this->enable_loop();
}
void LightState::start_flash_(const LightColorValues &target, uint32_t length, bool set_remote_values) {
@@ -265,6 +279,8 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b
if (set_remote_values) {
this->remote_values = target;
};
// Enable loop while flash is active
this->enable_loop();
}
void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) {
@@ -276,6 +292,14 @@ void LightState::set_immediately_(const LightColorValues &target, bool set_remot
}
this->output_->update_state(this);
this->next_write_ = true;
this->enable_loop();
}
void LightState::disable_loop_if_idle_() {
// Only disable loop if both transformer and effect are inactive, and no pending writes
if (this->transformer_ == nullptr && this->get_active_effect_() == nullptr && !this->next_write_) {
this->disable_loop();
}
}
void LightState::save_remote_values_() {

View File

@@ -255,6 +255,9 @@ class LightState : public EntityBase, public Component {
/// Internal method to save the current remote_values to the preferences
void save_remote_values_();
/// Disable loop if neither transformer nor effect is active
void disable_loop_if_idle_();
/// Store the output to allow effects to have more access.
LightOutput *output_;
/// The currently active transformer for this light (transition/flash).

View File

@@ -365,8 +365,10 @@ async def to_code(config):
if CORE.is_esp32:
if config[CONF_HARDWARE_UART] == USB_CDC:
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True)
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG:
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True)
cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG")
try:
uart_selection(USB_SERIAL_JTAG)
cg.add_define("USE_LOGGER_USB_SERIAL_JTAG")

View File

@@ -65,7 +65,9 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch
uint16_t buffer_at = 0; // Initialize buffer position
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, console_buffer, &buffer_at,
MAX_CONSOLE_LOG_MSG_SIZE);
this->write_msg_(console_buffer);
// Add newline if platform needs it (ESP32 doesn't add via write_msg_)
this->add_newline_to_buffer_if_needed_(console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE);
this->write_msg_(console_buffer, buffer_at);
}
// Reset the recursion guard for this task
@@ -131,18 +133,19 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas
// Save the offset before calling format_log_to_buffer_with_terminator_
// since it will increment tx_buffer_at_ to the end of the formatted string
uint32_t msg_start = this->tx_buffer_at_;
uint16_t msg_start = this->tx_buffer_at_;
this->format_log_to_buffer_with_terminator_(level, tag, line, this->tx_buffer_, args, this->tx_buffer_,
&this->tx_buffer_at_, this->tx_buffer_size_);
// Write to console and send callback starting at the msg_start
if (this->baud_rate_ > 0) {
this->write_msg_(this->tx_buffer_ + msg_start);
}
size_t msg_length =
uint16_t msg_length =
this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position
// Callbacks get message first (before console write)
this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length);
// Write to console starting at the msg_start
this->write_tx_buffer_to_console_(msg_start, &msg_length);
global_recursion_guard_ = false;
}
#endif // USE_STORE_LOG_STR_IN_FLASH
@@ -209,9 +212,7 @@ void Logger::process_messages_() {
// This ensures all log messages appear on the console in a clean, serialized manner
// Note: Messages may appear slightly out of order due to async processing, but
// this is preferred over corrupted/interleaved console output
if (this->baud_rate_ > 0) {
this->write_msg_(this->tx_buffer_);
}
this->write_tx_buffer_to_console_();
}
} else {
// No messages to process, disable loop if appropriate

View File

@@ -71,6 +71,17 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128;
// "0x" + 2 hex digits per byte + '\0'
static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1;
// Platform-specific: does write_msg_ add its own newline?
// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266)
// Allows single write call with newline included for efficiency
// true: write_msg_ adds newline itself via puts()/println() (other platforms)
// Newline should NOT be added to buffer
#if defined(USE_ESP32) || defined(USE_ESP8266)
static constexpr bool WRITE_MSG_ADDS_NEWLINE = false;
#else
static constexpr bool WRITE_MSG_ADDS_NEWLINE = true;
#endif
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
/** Enum for logging UART selection
*
@@ -173,7 +184,7 @@ class Logger : public Component {
protected:
void process_messages_();
void write_msg_(const char *msg);
void write_msg_(const char *msg, size_t len);
// Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator
// It's the caller's responsibility to initialize buffer_at (typically to 0)
@@ -200,6 +211,35 @@ class Logger : public Component {
}
}
// Helper to add newline to buffer for platforms that need it
// Modifies buffer_at to include the newline
inline void HOT add_newline_to_buffer_if_needed_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) {
if constexpr (!WRITE_MSG_ADDS_NEWLINE) {
// Add newline - don't need to maintain null termination
// write_msg_ now always receives explicit length, so we can safely overwrite the null terminator
// This is safe because:
// 1. Callbacks already received the message (before we add newline)
// 2. write_msg_ receives the length explicitly (doesn't need null terminator)
if (*buffer_at < buffer_size) {
buffer[(*buffer_at)++] = '\n';
} else if (buffer_size > 0) {
// Buffer was full - replace last char with newline to ensure it's visible
buffer[buffer_size - 1] = '\n';
*buffer_at = buffer_size;
}
}
}
// Helper to write tx_buffer_ to console if logging is enabled
// INTERNAL USE ONLY - offset > 0 requires length parameter to be non-null
inline void HOT write_tx_buffer_to_console_(uint16_t offset = 0, uint16_t *length = nullptr) {
if (this->baud_rate_ > 0) {
uint16_t *len_ptr = length ? length : &this->tx_buffer_at_;
this->add_newline_to_buffer_if_needed_(this->tx_buffer_ + offset, len_ptr, this->tx_buffer_size_ - offset);
this->write_msg_(this->tx_buffer_ + offset, *len_ptr);
}
}
// Helper to format and send a log message to both console and callbacks
inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format,
va_list args) {
@@ -208,10 +248,11 @@ class Logger : public Component {
this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, this->tx_buffer_, &this->tx_buffer_at_,
this->tx_buffer_size_);
if (this->baud_rate_ > 0) {
this->write_msg_(this->tx_buffer_); // If logging is enabled, write to console
}
// Callbacks get message WITHOUT newline (for API/MQTT/syslog)
this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_);
// Console gets message WITH newline (if platform needs it)
this->write_tx_buffer_to_console_();
}
// Write the body of the log message to the buffer
@@ -425,7 +466,9 @@ class Logger : public Component {
}
// Update buffer_at with the formatted length (handle truncation)
uint16_t formatted_len = (ret >= remaining) ? remaining : ret;
// When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator
// When it doesn't truncate (ret < remaining), it writes ret chars + null terminator
uint16_t formatted_len = (ret >= remaining) ? (remaining - 1) : ret;
*buffer_at += formatted_len;
// Remove all trailing newlines right after formatting

View File

@@ -121,25 +121,23 @@ void Logger::pre_setup() {
ESP_LOGI(TAG, "Log initialized");
}
void HOT Logger::write_msg_(const char *msg) {
if (
#if defined(USE_LOGGER_USB_CDC) && !defined(USE_LOGGER_USB_SERIAL_JTAG)
this->uart_ == UART_SELECTION_USB_CDC
#elif defined(USE_LOGGER_USB_SERIAL_JTAG) && !defined(USE_LOGGER_USB_CDC)
this->uart_ == UART_SELECTION_USB_SERIAL_JTAG
#elif defined(USE_LOGGER_USB_CDC) && defined(USE_LOGGER_USB_SERIAL_JTAG)
this->uart_ == UART_SELECTION_USB_CDC || this->uart_ == UART_SELECTION_USB_SERIAL_JTAG
void HOT Logger::write_msg_(const char *msg, size_t len) {
// Length is now always passed explicitly - no strlen() fallback needed
#if defined(USE_LOGGER_UART_SELECTION_USB_CDC) || defined(USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG)
// USB CDC/JTAG - single write including newline (already in buffer)
// Use fwrite to stdout which goes through VFS to USB console
//
// Note: These defines indicate the user's YAML configuration choice (hardware_uart: USB_CDC/USB_SERIAL_JTAG).
// They are ONLY defined when the user explicitly selects USB as the logger output in their config.
// This is compile-time selection, not runtime detection - if USB is configured, it's always used.
// There is no fallback to regular UART if "USB isn't connected" - that's the user's responsibility
// to configure correctly for their hardware. This approach eliminates runtime overhead.
fwrite(msg, 1, len, stdout);
#else
/* DISABLES CODE */ (false) // NOLINT
// Regular UART - single write including newline (already in buffer)
uart_write_bytes(this->uart_num_, msg, len);
#endif
) {
puts(msg);
} else {
// Use tx_buffer_at_ if msg points to tx_buffer_, otherwise fall back to strlen
size_t len = (msg == this->tx_buffer_) ? this->tx_buffer_at_ : strlen(msg);
uart_write_bytes(this->uart_num_, msg, len);
uart_write_bytes(this->uart_num_, "\n", 1);
}
}
const LogString *Logger::get_uart_selection_() {

View File

@@ -33,7 +33,10 @@ void Logger::pre_setup() {
ESP_LOGI(TAG, "Log initialized");
}
void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); }
void HOT Logger::write_msg_(const char *msg, size_t len) {
// Single write with newline already in buffer (added by caller)
this->hw_serial_->write(msg, len);
}
const LogString *Logger::get_uart_selection_() {
switch (this->uart_) {

View File

@@ -3,7 +3,7 @@
namespace esphome::logger {
void HOT Logger::write_msg_(const char *msg) {
void HOT Logger::write_msg_(const char *msg, size_t) {
time_t rawtime;
struct tm *timeinfo;
char buffer[80];

View File

@@ -49,7 +49,7 @@ void Logger::pre_setup() {
ESP_LOGI(TAG, "Log initialized");
}
void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); }
void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); }
const LogString *Logger::get_uart_selection_() {
switch (this->uart_) {

View File

@@ -27,7 +27,7 @@ void Logger::pre_setup() {
ESP_LOGI(TAG, "Log initialized");
}
void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); }
void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); }
const LogString *Logger::get_uart_selection_() {
switch (this->uart_) {

View File

@@ -62,7 +62,7 @@ void Logger::pre_setup() {
ESP_LOGI(TAG, "Log initialized");
}
void HOT Logger::write_msg_(const char *msg) {
void HOT Logger::write_msg_(const char *msg, size_t) {
#ifdef CONFIG_PRINTK
printk("%s\n", msg);
#endif

View File

@@ -35,3 +35,70 @@ DriverChip(
(0x10, 0x0C), (0x11, 0x0C), (0x12, 0x0C), (0x13, 0x0C), (0x30, 0x00),
],
)
# JC4880P443 Driver Configuration (ST7701)
# Using parameters from esp_lcd_st7701.h and the working full init sequence
# ----------------------------------------------------------------------------------------------------------------------
# * Resolution: 480x800
# * PCLK Frequency: 34 MHz
# * DSI Lane Bit Rate: 500 Mbps (using 2-Lane DSI configuration)
# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42)
# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166)
# ----------------------------------------------------------------------------------------------------------------------
DriverChip(
"JC4880P443",
width=480,
height=800,
hsync_back_porch=42,
hsync_pulse_width=12,
hsync_front_porch=42,
vsync_back_porch=8,
vsync_pulse_width=2,
vsync_front_porch=166,
pclk_frequency="34MHz",
lane_bit_rate="500Mbps",
swap_xy=cv.UNDEFINED,
color_order="RGB",
reset_pin=5,
initsequence=[
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x13),
(0xEF, 0x08),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x10),
(0xC0, 0x63, 0x00),
(0xC1, 0x0D, 0x02),
(0xC2, 0x10, 0x08),
(0xCC, 0x10),
(0xB0, 0x80, 0x09, 0x53, 0x0C, 0xD0, 0x07, 0x0C, 0x09, 0x09, 0x28, 0x06, 0xD4, 0x13, 0x69, 0x2B, 0x71),
(0xB1, 0x80, 0x94, 0x5A, 0x10, 0xD3, 0x06, 0x0A, 0x08, 0x08, 0x25, 0x03, 0xD3, 0x12, 0x66, 0x6A, 0x0D),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x11),
(0xB0, 0x5D),
(0xB1, 0x58),
(0xB2, 0x87),
(0xB3, 0x80),
(0xB5, 0x4E),
(0xB7, 0x85),
(0xB8, 0x21),
(0xB9, 0x10, 0x1F),
(0xBB, 0x03),
(0xBC, 0x00),
(0xC1, 0x78),
(0xC2, 0x78),
(0xD0, 0x88),
(0xE0, 0x00, 0x3A, 0x02),
(0xE1, 0x04, 0xA0, 0x00, 0xA0, 0x05, 0xA0, 0x00, 0xA0, 0x00, 0x40, 0x40),
(0xE2, 0x30, 0x00, 0x40, 0x40, 0x32, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00),
(0xE3, 0x00, 0x00, 0x33, 0x33),
(0xE4, 0x44, 0x44),
(0xE5, 0x09, 0x2E, 0xA0, 0xA0, 0x0B, 0x30, 0xA0, 0xA0, 0x05, 0x2A, 0xA0, 0xA0, 0x07, 0x2C, 0xA0, 0xA0),
(0xE6, 0x00, 0x00, 0x33, 0x33),
(0xE7, 0x44, 0x44),
(0xE8, 0x08, 0x2D, 0xA0, 0xA0, 0x0A, 0x2F, 0xA0, 0xA0, 0x04, 0x29, 0xA0, 0xA0, 0x06, 0x2B, 0xA0, 0xA0),
(0xEB, 0x00, 0x00, 0x4E, 0x4E, 0x00, 0x00, 0x00),
(0xEC, 0x08, 0x01),
(0xED, 0xB0, 0x2B, 0x98, 0xA4, 0x56, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0x65, 0x4A, 0x89, 0xB2, 0x0B),
(0xEF, 0x08, 0x08, 0x08, 0x45, 0x3F, 0x54),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x00),
]
)
# fmt: on

View File

@@ -1,8 +1,8 @@
#pragma once
#include <list>
#include <memory>
#include <tuple>
#include <forward_list>
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -290,10 +290,10 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
}
// Store parameters for later execution
this->param_queue_.emplace_front(x...);
// Enable loop now that we have work to do
this->param_queue_.emplace_back(x...);
// Enable loop now that we have work to do - don't call loop() synchronously!
// Let the event loop call it to avoid reentrancy issues
this->enable_loop();
this->loop();
}
void loop() override {
@@ -303,13 +303,17 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
if (this->script_->is_running())
return;
while (!this->param_queue_.empty()) {
// Only process ONE queued item per loop iteration
// Processing all items in a while loop causes infinite loops because
// play_next_() can trigger more items to be queued
if (!this->param_queue_.empty()) {
auto &params = this->param_queue_.front();
this->play_next_tuple_(params, typename gens<sizeof...(Ts)>::type());
this->param_queue_.pop_front();
} else {
// Queue is now empty - disable loop until next play_complex
this->disable_loop();
}
// Queue is now empty - disable loop until next play_complex
this->disable_loop();
}
void play(const Ts &...x) override { /* ignore - see play_complex */
@@ -326,7 +330,7 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
}
C *script_;
std::forward_list<std::tuple<Ts...>> param_queue_;
std::list<std::tuple<Ts...>> param_queue_;
};
} // namespace script

View File

@@ -111,7 +111,7 @@ class WebServerBase : public Component {
this->initialized_++;
return;
}
this->server_ = std::make_shared<AsyncWebServer>(this->port_);
this->server_ = std::make_unique<AsyncWebServer>(this->port_);
// All content is controlled and created by user - so allowing all origins is fine here.
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
this->server_->begin();
@@ -127,7 +127,7 @@ class WebServerBase : public Component {
this->server_ = nullptr;
}
}
std::shared_ptr<AsyncWebServer> get_server() const { return server_; }
AsyncWebServer *get_server() const { return this->server_.get(); }
float get_setup_priority() const override;
#ifdef USE_WEBSERVER_AUTH
@@ -143,7 +143,7 @@ class WebServerBase : public Component {
protected:
int initialized_{0};
uint16_t port_{80};
std::shared_ptr<AsyncWebServer> server_{nullptr};
std::unique_ptr<AsyncWebServer> server_{nullptr};
std::vector<AsyncWebHandler *> handlers_;
#ifdef USE_WEBSERVER_AUTH
internal::Credentials credentials_;

View File

@@ -9,8 +9,8 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include <list>
#include <vector>
#include <forward_list>
namespace esphome {
@@ -445,9 +445,10 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
// Store for later processing
auto now = millis();
auto timeout = this->timeout_value_.optional_value(x...);
this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...));
this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...));
// Do immediate check with fresh timestamp
// Do immediate check with fresh timestamp - don't call loop() synchronously!
// Let the event loop call it to avoid reentrancy issues
if (this->process_queue_(now)) {
// Only enable loop if we still have pending items
this->enable_loop();
@@ -499,7 +500,7 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
}
Condition<Ts...> *condition_;
std::forward_list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
std::list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
};
template<typename... Ts> class UpdateComponentAction : public Action<Ts...> {

View File

@@ -217,6 +217,7 @@
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 2)
#define USE_ETHERNET
#define USE_ETHERNET_KSZ8081
#define USE_ETHERNET_MANUAL_IP
#endif
#ifdef USE_ESP_IDF

View File

@@ -343,7 +343,13 @@ def clean_build(clear_pio_cache: bool = True):
def clean_all(configuration: list[str]):
import shutil
data_dirs = [Path(dir) / ".esphome" for dir in configuration]
data_dirs = []
for config in configuration:
item = Path(config)
if item.is_file() and item.suffix in (".yaml", ".yml"):
data_dirs.append(item.parent / ".esphome")
else:
data_dirs.append(item / ".esphome")
if is_ha_addon():
data_dirs.append(Path("/data"))
if "ESPHOME_DATA_DIR" in os.environ:

View File

@@ -0,0 +1,131 @@
esphome:
name: test-script-delay-params
host:
api:
actions:
# Test case from issue #12044: parent script with repeat calling child with delay
- action: test_repeat_with_delay
then:
- logger.log: "=== TEST: Repeat loop calling script with delay and parameters ==="
- script.execute: father_script
# Test case from issue #12043: script.wait with delayed child script
- action: test_script_wait
then:
- logger.log: "=== TEST: script.wait with delayed child script ==="
- script.execute: show_start_page
- script.wait: show_start_page
- logger.log: "After wait: script completed successfully"
# Test: Delay with different parameter types
- action: test_delay_param_types
then:
- logger.log: "=== TEST: Delay with various parameter types ==="
- script.execute:
id: delay_with_int
val: 42
- delay: 50ms
- script.execute:
id: delay_with_string
msg: "test message"
- delay: 50ms
- script.execute:
id: delay_with_float
num: 3.14
logger:
level: DEBUG
script:
# Reproduces issue #12044: child script with conditional delay
- id: son_script
mode: single
parameters:
iteration: int
then:
- logger.log:
format: "Son script started with iteration %d"
args: ['iteration']
- if:
condition:
lambda: 'return iteration >= 5;'
then:
- logger.log:
format: "Son script delaying for iteration %d"
args: ['iteration']
- delay: 100ms
- logger.log:
format: "Son script finished with iteration %d"
args: ['iteration']
# Reproduces issue #12044: parent script with repeat loop
- id: father_script
mode: single
then:
- repeat:
count: 10
then:
- logger.log:
format: "Father iteration %d: calling son"
args: ['iteration']
- script.execute:
id: son_script
iteration: !lambda 'return iteration;'
- script.wait: son_script
- logger.log:
format: "Father iteration %d: son finished, wait returned"
args: ['iteration']
# Reproduces issue #12043: script.wait hangs
- id: show_start_page
mode: single
then:
- logger.log: "Start page: beginning"
- delay: 100ms
- logger.log: "Start page: after delay"
- delay: 100ms
- logger.log: "Start page: completed"
# Test delay with int parameter
- id: delay_with_int
mode: single
parameters:
val: int
then:
- logger.log:
format: "Int test: before delay, val=%d"
args: ['val']
- delay: 50ms
- logger.log:
format: "Int test: after delay, val=%d"
args: ['val']
# Test delay with string parameter
- id: delay_with_string
mode: single
parameters:
msg: string
then:
- logger.log:
format: "String test: before delay, msg=%s"
args: ['msg.c_str()']
- delay: 50ms
- logger.log:
format: "String test: after delay, msg=%s"
args: ['msg.c_str()']
# Test delay with float parameter
- id: delay_with_float
mode: single
parameters:
num: float
then:
- logger.log:
format: "Float test: before delay, num=%.2f"
args: ['num']
- delay: 50ms
- logger.log:
format: "Float test: after delay, num=%.2f"
args: ['num']

View File

@@ -0,0 +1,82 @@
esphome:
name: test-wait-until-ordering
host:
api:
actions:
- action: test_wait_until_fifo
then:
- logger.log: "=== TEST: wait_until should execute in FIFO order ==="
- globals.set:
id: gate_open
value: 'false'
- delay: 100ms
# Start multiple parallel executions of coordinator script
# Each will call the shared waiter script, queueing in same wait_until
- script.execute: coordinator_0
- script.execute: coordinator_1
- script.execute: coordinator_2
- script.execute: coordinator_3
- script.execute: coordinator_4
# Give scripts time to reach wait_until and queue
- delay: 200ms
- logger.log: "Opening gate - all wait_until should complete now"
- globals.set:
id: gate_open
value: 'true'
- delay: 500ms
- logger.log: "Test complete"
globals:
- id: gate_open
type: bool
initial_value: 'false'
script:
# Shared waiter with single wait_until action (all coordinators call this)
- id: waiter
mode: parallel
parameters:
iter: int
then:
- lambda: 'ESP_LOGD("main", "Queueing iteration %d", iter);'
- wait_until:
condition:
lambda: 'return id(gate_open);'
timeout: 5s
- lambda: 'ESP_LOGD("main", "Completed iteration %d", iter);'
# Coordinator scripts - each calls shared waiter with different iteration number
- id: coordinator_0
then:
- script.execute:
id: waiter
iter: 0
- id: coordinator_1
then:
- script.execute:
id: waiter
iter: 1
- id: coordinator_2
then:
- script.execute:
id: waiter
iter: 2
- id: coordinator_3
then:
- script.execute:
id: waiter
iter: 3
- id: coordinator_4
then:
- script.execute:
id: waiter
iter: 4
logger:
level: DEBUG

View File

@@ -0,0 +1,121 @@
"""Integration test for script.wait FIFO ordering (issues #12043, #12044).
This test verifies that ScriptWaitAction processes queued items in FIFO order.
PR #7972 introduced bugs in ScriptWaitAction:
- Used emplace_front() causing LIFO ordering instead of FIFO
- Called loop() synchronously causing reentrancy issues
- Used while loop processing entire queue causing infinite loops
These bugs manifested as:
- Scripts becoming "zombies" (stuck in running state)
- script.wait hanging forever
- Incorrect execution order
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_delay_with_params(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that script.wait processes queued items in FIFO order.
This reproduces issues #12043 and #12044 where scripts would hang or become
zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972.
"""
test_complete = asyncio.Event()
# Patterns to match in logs
father_calling_pattern = re.compile(r"Father iteration (\d+): calling son")
son_started_pattern = re.compile(r"Son script started with iteration (\d+)")
son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)")
son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)")
father_wait_returned_pattern = re.compile(
r"Father iteration (\d+): son finished, wait returned"
)
# Track which iterations completed
father_calling = set()
son_started = set()
son_delaying = set()
son_finished = set()
wait_returned = set()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if test_complete.is_set():
return
if mo := father_calling_pattern.search(line):
father_calling.add(int(mo.group(1)))
elif mo := son_started_pattern.search(line):
son_started.add(int(mo.group(1)))
elif mo := son_delaying_pattern.search(line):
son_delaying.add(int(mo.group(1)))
elif mo := son_finished_pattern.search(line):
son_finished.add(int(mo.group(1)))
elif mo := father_wait_returned_pattern.search(line):
iteration = int(mo.group(1))
wait_returned.add(iteration)
# Test completes when iteration 9 finishes
if iteration == 9:
test_complete.set()
# 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 == "test-script-delay-params"
# Get services
_, services = await client.list_entities_services()
test_service = next(
(s for s in services if s.name == "test_repeat_with_delay"), None
)
assert test_service is not None, "test_repeat_with_delay service not found"
# Execute the test
client.execute_service(test_service, {})
# Wait for test to complete (10 iterations * ~100ms each + margin)
try:
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(
f"Test timed out. Completed iterations: {sorted(wait_returned)}. "
f"This likely indicates the script became a zombie (issue #12044)."
)
# Verify all 10 iterations completed successfully
expected_iterations = set(range(10))
assert father_calling == expected_iterations, "Not all iterations started"
assert son_started == expected_iterations, (
"Son script not started for all iterations"
)
assert son_finished == expected_iterations, (
"Son script not finished for all iterations"
)
assert wait_returned == expected_iterations, (
"script.wait did not return for all iterations"
)
# Verify delays were triggered for iterations >= 5
expected_delays = set(range(5, 10))
assert son_delaying == expected_delays, (
"Delays not triggered for iterations >= 5"
)

View File

@@ -0,0 +1,90 @@
"""Integration test for wait_until FIFO ordering.
This test verifies that when multiple wait_until actions are queued,
they execute in FIFO (First In First Out) order, not LIFO.
PR #7972 introduced a bug where emplace_front() was used, causing
LIFO ordering which is incorrect.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_wait_until_fifo_ordering(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that wait_until executes queued items in FIFO order.
With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO).
With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO).
"""
test_complete = asyncio.Event()
# Track completion order
completed_order = []
# Patterns to match
queuing_pattern = re.compile(r"Queueing iteration (\d+)")
completed_pattern = re.compile(r"Completed iteration (\d+)")
def check_output(line: str) -> None:
"""Check log output for completion order."""
if test_complete.is_set():
return
if mo := queuing_pattern.search(line):
iteration = int(mo.group(1))
elif mo := completed_pattern.search(line):
iteration = int(mo.group(1))
completed_order.append(iteration)
# Test completes when all 5 have completed
if len(completed_order) == 5:
test_complete.set()
# 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 == "test-wait-until-ordering"
# Get services
_, services = await client.list_entities_services()
test_service = next(
(s for s in services if s.name == "test_wait_until_fifo"), None
)
assert test_service is not None, "test_wait_until_fifo service not found"
# Execute the test
client.execute_service(test_service, {})
# Wait for test to complete
try:
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(
f"Test timed out. Completed order: {completed_order}. "
f"Expected 5 completions but got {len(completed_order)}."
)
# Verify FIFO order
expected_order = [0, 1, 2, 3, 4]
assert completed_order == expected_order, (
f"Unexpected order: {completed_order}. "
f"Expected FIFO order: {expected_order}"
)

View File

@@ -737,6 +737,37 @@ def test_write_cpp_with_duplicate_markers(
write_cpp("// New code")
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_file(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_all with a .yaml file uses parent directory."""
# Create config directory with yaml file
config_dir = tmp_path / "config"
config_dir.mkdir()
yaml_file = config_dir / "test.yaml"
yaml_file.write_text("esphome:\n name: test\n")
build_dir = config_dir / ".esphome"
build_dir.mkdir()
(build_dir / "dummy.txt").write_text("x")
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(yaml_file)])
# Verify .esphome directory still exists but contents cleaned
assert build_dir.exists()
assert not (build_dir / "dummy.txt").exists()
# Verify logging mentions the build dir
assert "Cleaning" in caplog.text
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all(
mock_core: MagicMock,