1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-18 15:55:46 +00:00

Merge branch 'dev' into climate_store_flash

This commit is contained in:
J. Nick Koston
2025-11-02 19:48:42 -06:00
committed by GitHub
75 changed files with 2155 additions and 651 deletions

View File

@@ -11,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.14.2 rev: v0.14.3
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -15,7 +15,7 @@ from esphome.const import (
CONF_TYPE_ID, CONF_TYPE_ID,
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL,
) )
from esphome.core import ID from esphome.core import ID, Lambda
from esphome.cpp_generator import ( from esphome.cpp_generator import (
LambdaExpression, LambdaExpression,
MockObj, MockObj,
@@ -182,7 +182,7 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
value = cv.Schema([extra_validators])(value) value = cv.Schema([extra_validators])(value)
if single: if single:
if len(value) != 1: if len(value) != 1:
raise cv.Invalid("Cannot have more than 1 automation for templates") raise cv.Invalid("This trigger allows only a single automation")
return value[0] return value[0]
return value return value
@@ -310,6 +310,30 @@ async def for_condition_to_code(
return var return var
@register_condition(
"component.is_idle",
LambdaCondition,
maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(cg.Component),
}
),
)
async def component_is_idle_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
comp = await cg.get_variable(config[CONF_ID])
lambda_ = await cg.process_lambda(
Lambda(f"return {comp}->is_idle();"), args, return_type=bool
)
return new_lambda_pvariable(
condition_id, lambda_, StatelessLambdaCondition, template_arg
)
@register_action( @register_action(
"delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds) "delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds)
) )

View File

@@ -425,7 +425,7 @@ message ListEntitiesFanResponse {
bool disabled_by_default = 9; bool disabled_by_default = 9;
string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"]; string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"];
EntityCategory entity_category = 11; EntityCategory entity_category = 11;
repeated string supported_preset_modes = 12 [(container_pointer) = "std::set"]; repeated string supported_preset_modes = 12 [(container_pointer_no_template) = "std::vector<const char *>"];
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
} }
// Deprecated in API version 1.6 - only used in deprecated fields // Deprecated in API version 1.6 - only used in deprecated fields

View File

@@ -423,7 +423,7 @@ uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *con
msg.supports_speed = traits.supports_speed(); msg.supports_speed = traits.supports_speed();
msg.supports_direction = traits.supports_direction(); msg.supports_direction = traits.supports_direction();
msg.supported_speed_count = traits.supported_speed_count(); msg.supported_speed_count = traits.supported_speed_count();
msg.supported_preset_modes = &traits.supported_preset_modes_for_api_(); msg.supported_preset_modes = &traits.supported_preset_modes();
return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single); return fill_and_encode_entity_info(fan, msg, ListEntitiesFanResponse::MESSAGE_TYPE, conn, remaining_size, is_single);
} }
void APIConnection::fan_command(const FanCommandRequest &msg) { void APIConnection::fan_command(const FanCommandRequest &msg) {

View File

@@ -434,8 +434,7 @@ APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, st
return APIError::OK; return APIError::OK;
} }
std::vector<uint8_t> *raw_buffer = buffer.get_buffer(); uint8_t *buffer_data = buffer.get_buffer()->data();
uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer
this->reusable_iovs_.clear(); this->reusable_iovs_.clear();
this->reusable_iovs_.reserve(packets.size()); this->reusable_iovs_.reserve(packets.size());

View File

@@ -230,8 +230,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer
return APIError::OK; return APIError::OK;
} }
std::vector<uint8_t> *raw_buffer = buffer.get_buffer(); uint8_t *buffer_data = buffer.get_buffer()->data();
uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer
this->reusable_iovs_.clear(); this->reusable_iovs_.clear();
this->reusable_iovs_.reserve(packets.size()); this->reusable_iovs_.reserve(packets.size());

View File

@@ -355,8 +355,8 @@ void ListEntitiesFanResponse::encode(ProtoWriteBuffer buffer) const {
buffer.encode_string(10, this->icon_ref_); buffer.encode_string(10, this->icon_ref_);
#endif #endif
buffer.encode_uint32(11, static_cast<uint32_t>(this->entity_category)); buffer.encode_uint32(11, static_cast<uint32_t>(this->entity_category));
for (const auto &it : *this->supported_preset_modes) { for (const char *it : *this->supported_preset_modes) {
buffer.encode_string(12, it, true); buffer.encode_string(12, it, strlen(it), true);
} }
#ifdef USE_DEVICES #ifdef USE_DEVICES
buffer.encode_uint32(13, this->device_id); buffer.encode_uint32(13, this->device_id);
@@ -376,8 +376,8 @@ void ListEntitiesFanResponse::calculate_size(ProtoSize &size) const {
#endif #endif
size.add_uint32(1, static_cast<uint32_t>(this->entity_category)); size.add_uint32(1, static_cast<uint32_t>(this->entity_category));
if (!this->supported_preset_modes->empty()) { if (!this->supported_preset_modes->empty()) {
for (const auto &it : *this->supported_preset_modes) { for (const char *it : *this->supported_preset_modes) {
size.add_length_force(1, it.size()); size.add_length_force(1, strlen(it));
} }
} }
#ifdef USE_DEVICES #ifdef USE_DEVICES

View File

@@ -725,7 +725,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage {
bool supports_speed{false}; bool supports_speed{false};
bool supports_direction{false}; bool supports_direction{false};
int32_t supported_speed_count{0}; int32_t supported_speed_count{0};
const std::set<std::string> *supported_preset_modes{}; const std::vector<const char *> *supported_preset_modes{};
void encode(ProtoWriteBuffer buffer) const override; void encode(ProtoWriteBuffer buffer) const override;
void calculate_size(ProtoSize &size) const override; void calculate_size(ProtoSize &size) const override;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP

View File

@@ -77,6 +77,9 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
} }
} else { } else {
this->node_state = espbt::ClientState::ESTABLISHED; this->node_state = espbt::ClientState::ESTABLISHED;
// For non-notify characteristics, trigger an immediate read after service discovery
// to avoid peripherals disconnecting due to inactivity
this->update();
} }
break; break;
} }

View File

@@ -79,6 +79,9 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
} }
} else { } else {
this->node_state = espbt::ClientState::ESTABLISHED; this->node_state = espbt::ClientState::ESTABLISHED;
// For non-notify characteristics, trigger an immediate read after service discovery
// to avoid peripherals disconnecting due to inactivity
this->update();
} }
break; break;
} }

View File

@@ -96,7 +96,11 @@ void loop_task(void *pv_params) {
extern "C" void app_main() { extern "C" void app_main() {
esp32::setup_preferences(); esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
xTaskCreate(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle); xTaskCreate(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle);
#else
xTaskCreatePinnedToCore(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle, 1);
#endif
} }
#endif // USE_ESP_IDF #endif // USE_ESP_IDF

View File

@@ -7,6 +7,7 @@ from typing import Any
from esphome import automation from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import socket
from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
@@ -481,6 +482,14 @@ async def to_code(config):
cg.add(var.set_name(name)) cg.add(var.set_name(name))
await cg.register_component(var, config) await cg.register_component(var, config)
# BLE uses 1 UDP socket for event notification to wake up main loop from select()
# This enables low-latency (~12μs) BLE event processing instead of waiting for
# select() timeout (0-16ms). The socket is created in ble_setup_() and used to
# wake lwip_select() when BLE events arrive from the BLE thread.
# Note: Called during config generation, socket is created at runtime. In practice,
# always used since esp32_ble only runs on ESP32 which always has USE_SOCKET_SELECT_SUPPORT.
socket.consume_sockets(1, "esp32_ble")(config)
# Define max connections for use in C++ code (e.g., ble_server.h) # Define max connections for use in C++ code (e.g., ble_server.h)
max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS)
cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections)

View File

@@ -27,10 +27,34 @@ extern "C" {
#include <esp32-hal-bt.h> #include <esp32-hal-bt.h>
#endif #endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#include <lwip/sockets.h>
#endif
namespace esphome::esp32_ble { namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble"; static const char *const TAG = "esp32_ble";
// GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_
#define GAP_SCAN_COMPLETE_EVENTS \
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT
#define GAP_ADV_COMPLETE_EVENTS \
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT
#define GAP_SECURITY_EVENTS \
case ESP_GAP_BLE_AUTH_CMPL_EVT: \
case ESP_GAP_BLE_SEC_REQ_EVT: \
case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: \
case ESP_GAP_BLE_PASSKEY_REQ_EVT: \
case ESP_GAP_BLE_NC_REQ_EVT
void ESP32BLE::setup() { void ESP32BLE::setup() {
global_ble = this; global_ble = this;
if (!ble_pre_setup_()) { if (!ble_pre_setup_()) {
@@ -273,10 +297,21 @@ bool ESP32BLE::ble_setup_() {
// BLE takes some time to be fully set up, 200ms should be more than enough // BLE takes some time to be fully set up, 200ms should be more than enough
delay(200); // NOLINT delay(200); // NOLINT
// Set up notification socket to wake main loop for BLE events
// This enables low-latency (~12μs) event processing instead of waiting for select() timeout
#ifdef USE_SOCKET_SELECT_SUPPORT
this->setup_event_notification_();
#endif
return true; return true;
} }
bool ESP32BLE::ble_dismantle_() { bool ESP32BLE::ble_dismantle_() {
// Clean up notification socket first before dismantling BLE stack
#ifdef USE_SOCKET_SELECT_SUPPORT
this->cleanup_event_notification_();
#endif
esp_err_t err = esp_bluedroid_disable(); esp_err_t err = esp_bluedroid_disable();
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err);
@@ -374,6 +409,12 @@ void ESP32BLE::loop() {
break; break;
} }
#ifdef USE_SOCKET_SELECT_SUPPORT
// Drain any notification socket events first
// This clears the socket so it doesn't stay "ready" in subsequent select() calls
this->drain_event_notifications_();
#endif
BLEEvent *ble_event = this->ble_events_.pop(); BLEEvent *ble_event = this->ble_events_.pop();
while (ble_event != nullptr) { while (ble_event != nullptr) {
switch (ble_event->type_) { switch (ble_event->type_) {
@@ -414,60 +455,48 @@ void ESP32BLE::loop() {
break; break;
// Scan complete events // Scan complete events
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: GAP_SCAN_COMPLETE_EVENTS:
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT: // Advertising complete events
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT: GAP_ADV_COMPLETE_EVENTS:
// RSSI complete event
case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT:
// Security events
GAP_SECURITY_EVENTS:
ESP_LOGV(TAG, "gap_event_handler - %d", gap_event);
#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT
{
esp_ble_gap_cb_param_t *param;
// clang-format off
switch (gap_event) {
// All three scan complete events have the same structure with just status // All three scan complete events have the same structure with just status
// The scan_complete struct matches ESP-IDF's layout exactly, so this reinterpret_cast is safe // The scan_complete struct matches ESP-IDF's layout exactly, so this reinterpret_cast is safe
// This is verified at compile-time by static_assert checks in ble_event.h // This is verified at compile-time by static_assert checks in ble_event.h
// The struct already contains our copy of the status (copied in BLEEvent constructor) // The struct already contains our copy of the status (copied in BLEEvent constructor)
ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); GAP_SCAN_COMPLETE_EVENTS:
#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT param = reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.scan_complete);
for (auto *gap_handler : this->gap_event_handlers_) {
gap_handler->gap_event_handler(
gap_event, reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.scan_complete));
}
#endif
break; break;
// Advertising complete events
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT:
// All advertising complete events have the same structure with just status // All advertising complete events have the same structure with just status
ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); GAP_ADV_COMPLETE_EVENTS:
#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT param = reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.adv_complete);
for (auto *gap_handler : this->gap_event_handlers_) {
gap_handler->gap_event_handler(
gap_event, reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.adv_complete));
}
#endif
break; break;
// RSSI complete event
case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT: case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT:
ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); param = reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.read_rssi_complete);
#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT
for (auto *gap_handler : this->gap_event_handlers_) {
gap_handler->gap_event_handler(
gap_event, reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.read_rssi_complete));
}
#endif
break; break;
// Security events GAP_SECURITY_EVENTS:
case ESP_GAP_BLE_AUTH_CMPL_EVT: param = reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.security);
case ESP_GAP_BLE_SEC_REQ_EVT: break;
case ESP_GAP_BLE_PASSKEY_NOTIF_EVT:
case ESP_GAP_BLE_PASSKEY_REQ_EVT: default:
case ESP_GAP_BLE_NC_REQ_EVT: break;
ESP_LOGV(TAG, "gap_event_handler - %d", gap_event); }
#ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT // clang-format on
// Dispatch to all registered handlers
for (auto *gap_handler : this->gap_event_handlers_) { for (auto *gap_handler : this->gap_event_handlers_) {
gap_handler->gap_event_handler( gap_handler->gap_event_handler(gap_event, param);
gap_event, reinterpret_cast<esp_ble_gap_cb_param_t *>(&ble_event->event_.gap.security)); }
} }
#endif #endif
break; break;
@@ -547,23 +576,13 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
// Queue GAP events that components need to handle // Queue GAP events that components need to handle
// Scanning events - used by esp32_ble_tracker // Scanning events - used by esp32_ble_tracker
case ESP_GAP_BLE_SCAN_RESULT_EVT: case ESP_GAP_BLE_SCAN_RESULT_EVT:
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: GAP_SCAN_COMPLETE_EVENTS:
case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
// Advertising events - used by esp32_ble_beacon and esp32_ble server // Advertising events - used by esp32_ble_beacon and esp32_ble server
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: GAP_ADV_COMPLETE_EVENTS:
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT:
// Connection events - used by ble_client // Connection events - used by ble_client
case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT: case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT:
// Security events - used by ble_client and bluetooth_proxy // Security events - used by ble_client and bluetooth_proxy
case ESP_GAP_BLE_AUTH_CMPL_EVT: GAP_SECURITY_EVENTS:
case ESP_GAP_BLE_SEC_REQ_EVT:
case ESP_GAP_BLE_PASSKEY_NOTIF_EVT:
case ESP_GAP_BLE_PASSKEY_REQ_EVT:
case ESP_GAP_BLE_NC_REQ_EVT:
enqueue_ble_event(event, param); enqueue_ble_event(event, param);
return; return;
@@ -584,6 +603,10 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param) { esp_ble_gatts_cb_param_t *param) {
enqueue_ble_event(event, gatts_if, param); enqueue_ble_event(event, gatts_if, param);
// Wake up main loop to process GATT event immediately
#ifdef USE_SOCKET_SELECT_SUPPORT
global_ble->notify_main_loop_();
#endif
} }
#endif #endif
@@ -591,6 +614,10 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat
void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) { esp_ble_gattc_cb_param_t *param) {
enqueue_ble_event(event, gattc_if, param); enqueue_ble_event(event, gattc_if, param);
// Wake up main loop to process GATT event immediately
#ifdef USE_SOCKET_SELECT_SUPPORT
global_ble->notify_main_loop_();
#endif
} }
#endif #endif
@@ -630,6 +657,89 @@ void ESP32BLE::dump_config() {
} }
} }
#ifdef USE_SOCKET_SELECT_SUPPORT
void ESP32BLE::setup_event_notification_() {
// Create UDP socket for event notifications
this->notify_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (this->notify_fd_ < 0) {
ESP_LOGW(TAG, "Event socket create failed: %d", errno);
return;
}
// Bind to loopback with auto-assigned port
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = lwip_htonl(INADDR_LOOPBACK);
addr.sin_port = 0; // Auto-assign port
if (lwip_bind(this->notify_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
ESP_LOGW(TAG, "Event socket bind failed: %d", errno);
lwip_close(this->notify_fd_);
this->notify_fd_ = -1;
return;
}
// Get the assigned address and connect to it
// Connecting a UDP socket allows using send() instead of sendto() for better performance
struct sockaddr_in notify_addr;
socklen_t len = sizeof(notify_addr);
if (lwip_getsockname(this->notify_fd_, (struct sockaddr *) &notify_addr, &len) < 0) {
ESP_LOGW(TAG, "Event socket address failed: %d", errno);
lwip_close(this->notify_fd_);
this->notify_fd_ = -1;
return;
}
// Connect to self (loopback) - allows using send() instead of sendto()
// After connect(), no need to store notify_addr - the socket remembers it
if (lwip_connect(this->notify_fd_, (struct sockaddr *) &notify_addr, sizeof(notify_addr)) < 0) {
ESP_LOGW(TAG, "Event socket connect failed: %d", errno);
lwip_close(this->notify_fd_);
this->notify_fd_ = -1;
return;
}
// Set non-blocking mode
int flags = lwip_fcntl(this->notify_fd_, F_GETFL, 0);
lwip_fcntl(this->notify_fd_, F_SETFL, flags | O_NONBLOCK);
// Register with application's select() loop
if (!App.register_socket_fd(this->notify_fd_)) {
ESP_LOGW(TAG, "Event socket register failed");
lwip_close(this->notify_fd_);
this->notify_fd_ = -1;
return;
}
ESP_LOGD(TAG, "Event socket ready");
}
void ESP32BLE::cleanup_event_notification_() {
if (this->notify_fd_ >= 0) {
App.unregister_socket_fd(this->notify_fd_);
lwip_close(this->notify_fd_);
this->notify_fd_ = -1;
ESP_LOGD(TAG, "Event socket closed");
}
}
void ESP32BLE::drain_event_notifications_() {
// Called from main loop to drain any pending notifications
// Must check is_socket_ready() to avoid blocking on empty socket
if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) {
char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE];
// Drain all pending notifications with non-blocking reads
// Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK
// We control both ends of this loopback socket (always write 1 byte per event),
// so no error checking needed - any errors indicate catastrophic system failure
while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) {
// Just draining, no action needed - actual BLE events are already queued
}
}
}
#endif // USE_SOCKET_SELECT_SUPPORT
uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) {
uint64_t u = 0; uint64_t u = 0;
u |= uint64_t(address[0] & 0xFF) << 40; u |= uint64_t(address[0] & 0xFF) << 40;

View File

@@ -25,6 +25,10 @@
#include <esp_gattc_api.h> #include <esp_gattc_api.h>
#include <esp_gatts_api.h> #include <esp_gatts_api.h>
#ifdef USE_SOCKET_SELECT_SUPPORT
#include <lwip/sockets.h>
#endif
namespace esphome::esp32_ble { namespace esphome::esp32_ble {
// Maximum size of the BLE event queue // Maximum size of the BLE event queue
@@ -162,6 +166,13 @@ class ESP32BLE : public Component {
void advertising_init_(); void advertising_init_();
#endif #endif
#ifdef USE_SOCKET_SELECT_SUPPORT
void setup_event_notification_(); // Create notification socket
void cleanup_event_notification_(); // Close and unregister socket
inline void notify_main_loop_(); // Wake up select() from BLE thread (hot path - inlined)
void drain_event_notifications_(); // Read pending notifications in main loop
#endif
private: private:
template<typename... Args> friend void enqueue_ble_event(Args... args); template<typename... Args> friend void enqueue_ble_event(Args... args);
@@ -196,6 +207,13 @@ class ESP32BLE : public Component {
esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum)
uint32_t advertising_cycle_time_{}; // 4 bytes uint32_t advertising_cycle_time_{}; // 4 bytes
#ifdef USE_SOCKET_SELECT_SUPPORT
// Event notification socket for waking up main loop from BLE thread
// Uses connected UDP loopback socket to wake lwip_select() with ~12μs latency vs 0-16ms timeout
// Socket is connected during setup, allowing use of send() instead of sendto() for efficiency
int notify_fd_{-1}; // 4 bytes (file descriptor)
#endif
// 2-byte aligned members // 2-byte aligned members
uint16_t appearance_{0}; // 2 bytes uint16_t appearance_{0}; // 2 bytes
@@ -207,6 +225,29 @@ class ESP32BLE : public Component {
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern ESP32BLE *global_ble; extern ESP32BLE *global_ble;
#ifdef USE_SOCKET_SELECT_SUPPORT
// Inline implementations for hot-path functions
// These are called from BLE thread (notify) and main loop (drain) on every event
// Small buffer for draining notification bytes (1 byte sent per BLE event)
// Size allows draining multiple notifications per recvfrom() without wasting stack
static constexpr size_t BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE = 16;
inline void ESP32BLE::notify_main_loop_() {
// Called from BLE thread context when events are queued
// Wakes up lwip_select() in main loop by writing to connected loopback socket
if (this->notify_fd_ >= 0) {
const char dummy = 1;
// Non-blocking send - if it fails (unlikely), select() will wake on timeout anyway
// No error checking needed: we control both ends of this loopback socket, and the
// BLE event is already queued. Notification is best-effort to reduce latency.
// This is safe to call from BLE thread - send() is thread-safe in lwip
// Socket is already connected to loopback address, so send() is faster than sendto()
lwip_send(this->notify_fd_, &dummy, 1, 0);
}
}
#endif // USE_SOCKET_SELECT_SUPPORT
template<typename... Ts> class BLEEnabledCondition : public Condition<Ts...> { template<typename... Ts> class BLEEnabledCondition : public Condition<Ts...> {
public: public:
bool check(Ts... x) override { return global_ble->is_active(); } bool check(Ts... x) override { return global_ble->is_active(); }

View File

@@ -281,19 +281,15 @@ void ESPHomeOTAComponent::handle_data_() {
#endif #endif
// Acknowledge auth OK - 1 byte // Acknowledge auth OK - 1 byte
buf[0] = ota::OTA_RESPONSE_AUTH_OK; this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
this->writeall_(buf, 1);
// Read size, 4 bytes MSB first // Read size, 4 bytes MSB first
if (!this->readall_(buf, 4)) { if (!this->readall_(buf, 4)) {
this->log_read_error_(LOG_STR("size")); this->log_read_error_(LOG_STR("size"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto) goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} }
ota_size = 0; ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
for (uint8_t i = 0; i < 4; i++) { (static_cast<size_t>(buf[2]) << 8) | buf[3];
ota_size <<= 8;
ota_size |= buf[i];
}
ESP_LOGV(TAG, "Size is %u bytes", ota_size); ESP_LOGV(TAG, "Size is %u bytes", ota_size);
// Now that we've passed authentication and are actually // Now that we've passed authentication and are actually
@@ -313,8 +309,7 @@ void ESPHomeOTAComponent::handle_data_() {
update_started = true; update_started = true;
// Acknowledge prepare OK - 1 byte // Acknowledge prepare OK - 1 byte
buf[0] = ota::OTA_RESPONSE_UPDATE_PREPARE_OK; this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
this->writeall_(buf, 1);
// Read binary MD5, 32 bytes // Read binary MD5, 32 bytes
if (!this->readall_(buf, 32)) { if (!this->readall_(buf, 32)) {
@@ -326,8 +321,7 @@ void ESPHomeOTAComponent::handle_data_() {
this->backend_->set_update_md5(sbuf); this->backend_->set_update_md5(sbuf);
// Acknowledge MD5 OK - 1 byte // Acknowledge MD5 OK - 1 byte
buf[0] = ota::OTA_RESPONSE_BIN_MD5_OK; this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
this->writeall_(buf, 1);
while (total < ota_size) { while (total < ota_size) {
// TODO: timeout check // TODO: timeout check
@@ -354,8 +348,7 @@ void ESPHomeOTAComponent::handle_data_() {
total += read; total += read;
#if USE_OTA_VERSION == 2 #if USE_OTA_VERSION == 2
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
buf[0] = ota::OTA_RESPONSE_CHUNK_OK; this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
this->writeall_(buf, 1);
size_acknowledged += OTA_BLOCK_SIZE; size_acknowledged += OTA_BLOCK_SIZE;
} }
#endif #endif
@@ -374,8 +367,7 @@ void ESPHomeOTAComponent::handle_data_() {
} }
// Acknowledge receive OK - 1 byte // Acknowledge receive OK - 1 byte
buf[0] = ota::OTA_RESPONSE_RECEIVE_OK; this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
this->writeall_(buf, 1);
error_code = this->backend_->end(); error_code = this->backend_->end();
if (error_code != ota::OTA_RESPONSE_OK) { if (error_code != ota::OTA_RESPONSE_OK) {
@@ -384,8 +376,7 @@ void ESPHomeOTAComponent::handle_data_() {
} }
// Acknowledge Update end OK - 1 byte // Acknowledge Update end OK - 1 byte
buf[0] = ota::OTA_RESPONSE_UPDATE_END_OK; this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
this->writeall_(buf, 1);
// Read ACK // Read ACK
if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
@@ -404,8 +395,7 @@ void ESPHomeOTAComponent::handle_data_() {
App.safe_reboot(); App.safe_reboot();
error: error:
buf[0] = static_cast<uint8_t>(error_code); this->write_byte_(static_cast<uint8_t>(error_code));
this->writeall_(buf, 1);
this->cleanup_connection_(); this->cleanup_connection_();
if (this->backend_ != nullptr && update_started) { if (this->backend_ != nullptr && update_started) {

View File

@@ -53,6 +53,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent {
#endif // USE_OTA_PASSWORD #endif // USE_OTA_PASSWORD
bool readall_(uint8_t *buf, size_t len); bool readall_(uint8_t *buf, size_t len);
bool writeall_(const uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len);
inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); }
bool try_read_(size_t to_read, const LogString *desc); bool try_read_(size_t to_read, const LogString *desc);
bool try_write_(size_t to_write, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc);

View File

@@ -51,7 +51,14 @@ void FanCall::validate_() {
if (!this->preset_mode_.empty()) { if (!this->preset_mode_.empty()) {
const auto &preset_modes = traits.supported_preset_modes(); const auto &preset_modes = traits.supported_preset_modes();
if (preset_modes.find(this->preset_mode_) == preset_modes.end()) { bool found = false;
for (const auto &mode : preset_modes) {
if (strcmp(mode, this->preset_mode_.c_str()) == 0) {
found = true;
break;
}
}
if (!found) {
ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str());
this->preset_mode_.clear(); this->preset_mode_.clear();
} }
@@ -92,11 +99,12 @@ FanCall FanRestoreState::to_call(Fan &fan) {
call.set_speed(this->speed); call.set_speed(this->speed);
call.set_direction(this->direction); call.set_direction(this->direction);
if (fan.get_traits().supports_preset_modes()) { auto traits = fan.get_traits();
if (traits.supports_preset_modes()) {
// Use stored preset index to get preset name // Use stored preset index to get preset name
const auto &preset_modes = fan.get_traits().supported_preset_modes(); const auto &preset_modes = traits.supported_preset_modes();
if (this->preset_mode < preset_modes.size()) { if (this->preset_mode < preset_modes.size()) {
call.set_preset_mode(*std::next(preset_modes.begin(), this->preset_mode)); call.set_preset_mode(preset_modes[this->preset_mode]);
} }
} }
return call; return call;
@@ -107,11 +115,12 @@ void FanRestoreState::apply(Fan &fan) {
fan.speed = this->speed; fan.speed = this->speed;
fan.direction = this->direction; fan.direction = this->direction;
if (fan.get_traits().supports_preset_modes()) { auto traits = fan.get_traits();
if (traits.supports_preset_modes()) {
// Use stored preset index to get preset name // Use stored preset index to get preset name
const auto &preset_modes = fan.get_traits().supported_preset_modes(); const auto &preset_modes = traits.supported_preset_modes();
if (this->preset_mode < preset_modes.size()) { if (this->preset_mode < preset_modes.size()) {
fan.preset_mode = *std::next(preset_modes.begin(), this->preset_mode); fan.preset_mode = preset_modes[this->preset_mode];
} }
} }
fan.publish_state(); fan.publish_state();
@@ -182,18 +191,25 @@ void Fan::save_state_() {
return; return;
} }
auto traits = this->get_traits();
FanRestoreState state{}; FanRestoreState state{};
state.state = this->state; state.state = this->state;
state.oscillating = this->oscillating; state.oscillating = this->oscillating;
state.speed = this->speed; state.speed = this->speed;
state.direction = this->direction; state.direction = this->direction;
if (this->get_traits().supports_preset_modes() && !this->preset_mode.empty()) { if (traits.supports_preset_modes() && !this->preset_mode.empty()) {
const auto &preset_modes = this->get_traits().supported_preset_modes(); const auto &preset_modes = traits.supported_preset_modes();
// Store index of current preset mode // Store index of current preset mode
auto preset_iterator = preset_modes.find(this->preset_mode); size_t i = 0;
if (preset_iterator != preset_modes.end()) for (const auto &mode : preset_modes) {
state.preset_mode = std::distance(preset_modes.begin(), preset_iterator); if (strcmp(mode, this->preset_mode.c_str()) == 0) {
state.preset_mode = i;
break;
}
i++;
}
} }
this->rtc_.save(&state); this->rtc_.save(&state);
@@ -216,8 +232,8 @@ void Fan::dump_traits_(const char *tag, const char *prefix) {
} }
if (traits.supports_preset_modes()) { if (traits.supports_preset_modes()) {
ESP_LOGCONFIG(tag, "%s Supported presets:", prefix); ESP_LOGCONFIG(tag, "%s Supported presets:", prefix);
for (const std::string &s : traits.supported_preset_modes()) for (const char *s : traits.supported_preset_modes())
ESP_LOGCONFIG(tag, "%s - %s", prefix, s.c_str()); ESP_LOGCONFIG(tag, "%s - %s", prefix, s);
} }
} }

View File

@@ -1,15 +1,9 @@
#include <set>
#include <utility>
#pragma once #pragma once
namespace esphome { #include <vector>
#include <initializer_list>
#ifdef USE_API namespace esphome {
namespace api {
class APIConnection;
} // namespace api
#endif
namespace fan { namespace fan {
@@ -36,27 +30,27 @@ class FanTraits {
/// Set whether this fan supports changing direction /// Set whether this fan supports changing direction
void set_direction(bool direction) { this->direction_ = direction; } void set_direction(bool direction) { this->direction_ = direction; }
/// Return the preset modes supported by the fan. /// Return the preset modes supported by the fan.
std::set<std::string> supported_preset_modes() const { return this->preset_modes_; } const std::vector<const char *> &supported_preset_modes() const { return this->preset_modes_; }
/// Set the preset modes supported by the fan. /// Set the preset modes supported by the fan (from initializer list).
void set_supported_preset_modes(const std::set<std::string> &preset_modes) { this->preset_modes_ = preset_modes; } void set_supported_preset_modes(std::initializer_list<const char *> preset_modes) {
this->preset_modes_ = preset_modes;
}
/// Set the preset modes supported by the fan (from vector).
void set_supported_preset_modes(const std::vector<const char *> &preset_modes) { this->preset_modes_ = preset_modes; }
// Deleted overloads to catch incorrect std::string usage at compile time with clear error messages
void set_supported_preset_modes(const std::vector<std::string> &preset_modes) = delete;
void set_supported_preset_modes(std::initializer_list<std::string> preset_modes) = delete;
/// Return if preset modes are supported /// Return if preset modes are supported
bool supports_preset_modes() const { return !this->preset_modes_.empty(); } bool supports_preset_modes() const { return !this->preset_modes_.empty(); }
protected: protected:
#ifdef USE_API
// The API connection is a friend class to access internal methods
friend class api::APIConnection;
// This method returns a reference to the internal preset modes set.
// It is used by the API to avoid copying data when encoding messages.
// Warning: Do not use this method outside of the API connection code.
// It returns a reference to internal data that can be invalidated.
const std::set<std::string> &supported_preset_modes_for_api_() const { return this->preset_modes_; }
#endif
bool oscillation_{false}; bool oscillation_{false};
bool speed_{false}; bool speed_{false};
bool direction_{false}; bool direction_{false};
int speed_count_{}; int speed_count_{};
std::set<std::string> preset_modes_{}; std::vector<const char *> preset_modes_{};
}; };
} // namespace fan } // namespace fan

View File

@@ -39,6 +39,7 @@ CONFIG_SCHEMA = (
# due to hardware limitations or lack of reliable interrupt support. This ensures # due to hardware limitations or lack of reliable interrupt support. This ensures
# stable operation on these platforms. Future maintainers should verify platform # stable operation on these platforms. Future maintainers should verify platform
# capabilities before changing this default behavior. # capabilities before changing this default behavior.
# nrf52 has no gpio interrupts implemented yet
cv.SplitDefault( cv.SplitDefault(
CONF_USE_INTERRUPT, CONF_USE_INTERRUPT,
bk72xx=False, bk72xx=False,
@@ -46,7 +47,7 @@ CONFIG_SCHEMA = (
esp8266=True, esp8266=True,
host=True, host=True,
ln882x=False, ln882x=False,
nrf52=True, nrf52=False,
rp2040=True, rp2040=True,
rtl87xx=False, rtl87xx=False,
): cv.boolean, ): cv.boolean,

View File

@@ -1,7 +1,5 @@
#pragma once #pragma once
#include <set>
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "esphome/components/output/binary_output.h" #include "esphome/components/output/binary_output.h"
#include "esphome/components/output/float_output.h" #include "esphome/components/output/float_output.h"
@@ -22,7 +20,7 @@ class HBridgeFan : public Component, public fan::Fan {
void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; }
void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; }
void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; }
void set_preset_modes(const std::set<std::string> &presets) { preset_modes_ = presets; } void set_preset_modes(std::initializer_list<const char *> presets) { preset_modes_ = presets; }
void setup() override; void setup() override;
void dump_config() override; void dump_config() override;
@@ -38,7 +36,7 @@ class HBridgeFan : public Component, public fan::Fan {
int speed_count_{}; int speed_count_{};
DecayMode decay_mode_{DECAY_MODE_SLOW}; DecayMode decay_mode_{DECAY_MODE_SLOW};
fan::FanTraits traits_; fan::FanTraits traits_;
std::set<std::string> preset_modes_{}; std::vector<const char *> preset_modes_{};
void control(const fan::FanCall &call) override; void control(const fan::FanCall &call) override;
void write_state_(); void write_state_();

View File

@@ -671,10 +671,15 @@ async def write_image(config, all_frames=False):
resize = config.get(CONF_RESIZE) resize = config.get(CONF_RESIZE)
if is_svg_file(path): if is_svg_file(path):
# Local import so use of non-SVG files needn't require cairosvg installed # Local import so use of non-SVG files needn't require cairosvg installed
from pyexpat import ExpatError
from xml.etree.ElementTree import ParseError
from cairosvg import svg2png from cairosvg import svg2png
from cairosvg.helpers import PointError
if not resize: if not resize:
resize = (None, None) resize = (None, None)
try:
with open(path, "rb") as file: with open(path, "rb") as file:
image = svg2png( image = svg2png(
file_obj=file, file_obj=file,
@@ -683,6 +688,16 @@ async def write_image(config, all_frames=False):
) )
image = Image.open(io.BytesIO(image)) image = Image.open(io.BytesIO(image))
width, height = image.size width, height = image.size
except (
ValueError,
ParseError,
IndexError,
ExpatError,
AttributeError,
TypeError,
PointError,
) as e:
raise core.EsphomeError(f"Could not load SVG image {path}: {e}") from e
else: else:
image = Image.open(path) image = Image.open(path)
width, height = image.size width, height = image.size

View File

@@ -58,7 +58,7 @@ from .types import (
FontEngine, FontEngine,
IdleTrigger, IdleTrigger,
ObjUpdateAction, ObjUpdateAction,
PauseTrigger, PlainTrigger,
lv_font_t, lv_font_t,
lv_group_t, lv_group_t,
lv_style_t, lv_style_t,
@@ -151,6 +151,13 @@ for w_type in WIDGET_TYPES.values():
create_modify_schema(w_type), create_modify_schema(w_type),
)(update_to_code) )(update_to_code)
SIMPLE_TRIGGERS = (
df.CONF_ON_PAUSE,
df.CONF_ON_RESUME,
df.CONF_ON_DRAW_START,
df.CONF_ON_DRAW_END,
)
def as_macro(macro, value): def as_macro(macro, value):
if value is None: if value is None:
@@ -244,9 +251,9 @@ def final_validation(configs):
for w in refreshed_widgets: for w in refreshed_widgets:
path = global_config.get_path_for_id(w) path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1]) widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, Lambda) for v in widget_conf.values()): if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid( raise cv.Invalid(
f"Widget '{w}' does not have any templated properties to refresh", f"Widget '{w}' does not have any dynamic properties to refresh",
) )
@@ -366,16 +373,16 @@ async def to_code(configs):
conf[CONF_TRIGGER_ID], lv_component, templ conf[CONF_TRIGGER_ID], lv_component, templ
) )
await build_automation(idle_trigger, [], conf) await build_automation(idle_trigger, [], conf)
for conf in config.get(df.CONF_ON_PAUSE, ()): for trigger_name in SIMPLE_TRIGGERS:
pause_trigger = cg.new_Pvariable( if conf := config.get(trigger_name):
conf[CONF_TRIGGER_ID], lv_component, True trigger_var = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await build_automation(trigger_var, [], conf)
cg.add(
getattr(
lv_component,
f"set_{trigger_name.removeprefix('on_')}_trigger",
)(trigger_var)
) )
await build_automation(pause_trigger, [], conf)
for conf in config.get(df.CONF_ON_RESUME, ()):
resume_trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], lv_component, False
)
await build_automation(resume_trigger, [], conf)
await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) await add_on_boot_triggers(config.get(CONF_ON_BOOT, ()))
# This must be done after all widgets are created # This must be done after all widgets are created
@@ -443,16 +450,15 @@ LVGL_SCHEMA = cv.All(
), ),
} }
), ),
cv.Optional(df.CONF_ON_PAUSE): validate_automation( **{
cv.Optional(x): validate_automation(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
} },
), single=True,
cv.Optional(df.CONF_ON_RESUME): validate_automation( )
{ for x in SIMPLE_TRIGGERS
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), },
}
),
cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list( cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list(
WIDGET_SCHEMA WIDGET_SCHEMA
), ),

View File

@@ -137,7 +137,11 @@ async def lvgl_is_idle(config, condition_id, template_arg, args):
lvgl = config[CONF_LVGL_ID] lvgl = config[CONF_LVGL_ID]
timeout = await lv_milliseconds.process(config[CONF_TIMEOUT]) timeout = await lv_milliseconds.process(config[CONF_TIMEOUT])
async with LambdaContext(LVGL_COMP_ARG, return_type=cg.bool_) as context: async with LambdaContext(LVGL_COMP_ARG, return_type=cg.bool_) as context:
lv_add(ReturnStatement(lvgl_comp.is_idle(timeout))) lv_add(
ReturnStatement(
lv_expr.disp_get_inactive_time(lvgl_comp.get_disp()) > timeout
)
)
var = cg.new_Pvariable( var = cg.new_Pvariable(
condition_id, condition_id,
TemplateArguments(LvglComponent, *template_arg), TemplateArguments(LvglComponent, *template_arg),
@@ -400,7 +404,8 @@ async def obj_refresh_to_code(config, action_id, template_arg, args):
# must pass all widget-specific options here, even if not templated, but only do so if at least one is # must pass all widget-specific options here, even if not templated, but only do so if at least one is
# templated. First filter out common style properties. # templated. First filter out common style properties.
config = {k: v for k, v in widget.config.items() if k not in ALL_STYLES} config = {k: v for k, v in widget.config.items() if k not in ALL_STYLES}
if any(isinstance(v, Lambda) for v in config.values()): # Check if v is a Lambda or a dict, implying it is dynamic
if any(isinstance(v, (Lambda, dict)) for v in config.values()):
await widget.type.to_code(widget, config) await widget.type.to_code(widget, config)
if ( if (
widget.type.w_type.value_property is not None widget.type.w_type.value_property is not None

View File

@@ -31,7 +31,7 @@ async def to_code(config):
lvgl_static.add_event_cb( lvgl_static.add_event_cb(
widget.obj, widget.obj,
await pressed_ctx.get_lambda(), await pressed_ctx.get_lambda(),
LV_EVENT.PRESSING, LV_EVENT.PRESSED,
LV_EVENT.RELEASED, LV_EVENT.RELEASED,
) )
) )

View File

@@ -483,6 +483,8 @@ CONF_MSGBOXES = "msgboxes"
CONF_OBJ = "obj" CONF_OBJ = "obj"
CONF_ONE_CHECKED = "one_checked" CONF_ONE_CHECKED = "one_checked"
CONF_ONE_LINE = "one_line" CONF_ONE_LINE = "one_line"
CONF_ON_DRAW_START = "on_draw_start"
CONF_ON_DRAW_END = "on_draw_end"
CONF_ON_PAUSE = "on_pause" CONF_ON_PAUSE = "on_pause"
CONF_ON_RESUME = "on_resume" CONF_ON_RESUME = "on_resume"
CONF_ON_SELECT = "on_select" CONF_ON_SELECT = "on_select"

View File

@@ -82,6 +82,18 @@ static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) {
area->y2 = (area->y2 + draw_rounding) / draw_rounding * draw_rounding - 1; area->y2 = (area->y2 + draw_rounding) / draw_rounding * draw_rounding - 1;
} }
void LvglComponent::monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px) {
ESP_LOGVV(TAG, "Draw end: %" PRIu32 " pixels in %" PRIu32 " ms", px, time);
auto *comp = static_cast<LvglComponent *>(disp_drv->user_data);
comp->draw_end_();
}
void LvglComponent::render_start_cb(lv_disp_drv_t *disp_drv) {
ESP_LOGVV(TAG, "Draw start");
auto *comp = static_cast<LvglComponent *>(disp_drv->user_data);
comp->draw_start_();
}
lv_event_code_t lv_api_event; // NOLINT lv_event_code_t lv_api_event; // NOLINT
lv_event_code_t lv_update_event; // NOLINT lv_event_code_t lv_update_event; // NOLINT
void LvglComponent::dump_config() { void LvglComponent::dump_config() {
@@ -101,7 +113,10 @@ void LvglComponent::set_paused(bool paused, bool show_snow) {
lv_disp_trig_activity(this->disp_); // resets the inactivity time lv_disp_trig_activity(this->disp_); // resets the inactivity time
lv_obj_invalidate(lv_scr_act()); lv_obj_invalidate(lv_scr_act());
} }
this->pause_callbacks_.call(paused); if (paused && this->pause_callback_ != nullptr)
this->pause_callback_->trigger();
if (!paused && this->resume_callback_ != nullptr)
this->resume_callback_->trigger();
} }
void LvglComponent::esphome_lvgl_init() { void LvglComponent::esphome_lvgl_init() {
@@ -225,13 +240,6 @@ IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue<uint32_t> timeo
}); });
} }
PauseTrigger::PauseTrigger(LvglComponent *parent, TemplatableValue<bool> paused) : paused_(std::move(paused)) {
parent->add_on_pause_callback([this](bool pausing) {
if (this->paused_.value() == pausing)
this->trigger();
});
}
#ifdef USE_LVGL_TOUCHSCREEN #ifdef USE_LVGL_TOUCHSCREEN
LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) { LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) {
this->set_parent(parent); this->set_parent(parent);
@@ -474,6 +482,12 @@ void LvglComponent::setup() {
return; return;
} }
} }
if (this->draw_start_callback_ != nullptr) {
this->disp_drv_.render_start_cb = render_start_cb;
}
if (this->draw_end_callback_ != nullptr) {
this->disp_drv_.monitor_cb = monitor_cb;
}
#if LV_USE_LOG #if LV_USE_LOG
lv_log_register_print_cb([](const char *buf) { lv_log_register_print_cb([](const char *buf) {
auto next = strchr(buf, ')'); auto next = strchr(buf, ')');
@@ -502,8 +516,9 @@ void LvglComponent::loop() {
if (this->paused_) { if (this->paused_) {
if (this->show_snow_) if (this->show_snow_)
this->write_random_(); this->write_random_();
} } else {
lv_timer_handler_run_in_period(5); lv_timer_handler_run_in_period(5);
}
} }
#ifdef USE_LVGL_ANIMIMG #ifdef USE_LVGL_ANIMIMG

View File

@@ -171,9 +171,10 @@ class LvglComponent : public PollingComponent {
void add_on_idle_callback(std::function<void(uint32_t)> &&callback) { void add_on_idle_callback(std::function<void(uint32_t)> &&callback) {
this->idle_callbacks_.add(std::move(callback)); this->idle_callbacks_.add(std::move(callback));
} }
void add_on_pause_callback(std::function<void(bool)> &&callback) { this->pause_callbacks_.add(std::move(callback)); }
static void monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px);
static void render_start_cb(lv_disp_drv_t *disp_drv);
void dump_config() override; void dump_config() override;
bool is_idle(uint32_t idle_ms) { return lv_disp_get_inactive_time(this->disp_) > idle_ms; }
lv_disp_t *get_disp() { return this->disp_; } lv_disp_t *get_disp() { return this->disp_; }
lv_obj_t *get_scr_act() { return lv_disp_get_scr_act(this->disp_); } lv_obj_t *get_scr_act() { return lv_disp_get_scr_act(this->disp_); }
// Pause or resume the display. // Pause or resume the display.
@@ -213,12 +214,20 @@ class LvglComponent : public PollingComponent {
size_t draw_rounding{2}; size_t draw_rounding{2};
display::DisplayRotation rotation{display::DISPLAY_ROTATION_0_DEGREES}; display::DisplayRotation rotation{display::DISPLAY_ROTATION_0_DEGREES};
void set_pause_trigger(Trigger<> *trigger) { this->pause_callback_ = trigger; }
void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; }
void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; }
void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; }
protected: protected:
// these functions are never called unless the callbacks are non-null since the
// LVGL callbacks that call them are not set unless the start/end callbacks are non-null
void draw_start_() const { this->draw_start_callback_->trigger(); }
void draw_end_() const { this->draw_end_callback_->trigger(); }
void write_random_(); void write_random_();
void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); void draw_buffer_(const lv_area_t *area, lv_color_t *ptr);
void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p);
std::vector<display::Display *> displays_{}; std::vector<display::Display *> displays_{};
size_t buffer_frac_{1}; size_t buffer_frac_{1};
bool full_refresh_{}; bool full_refresh_{};
@@ -235,7 +244,10 @@ class LvglComponent : public PollingComponent {
std::map<lv_group_t *, lv_obj_t *> focus_marks_{}; std::map<lv_group_t *, lv_obj_t *> focus_marks_{};
CallbackManager<void(uint32_t)> idle_callbacks_{}; CallbackManager<void(uint32_t)> idle_callbacks_{};
CallbackManager<void(bool)> pause_callbacks_{}; Trigger<> *pause_callback_{};
Trigger<> *resume_callback_{};
Trigger<> *draw_start_callback_{};
Trigger<> *draw_end_callback_{};
lv_color_t *rotate_buf_{}; lv_color_t *rotate_buf_{};
}; };
@@ -248,14 +260,6 @@ class IdleTrigger : public Trigger<> {
bool is_idle_{}; bool is_idle_{};
}; };
class PauseTrigger : public Trigger<> {
public:
explicit PauseTrigger(LvglComponent *parent, TemplatableValue<bool> paused);
protected:
TemplatableValue<bool> paused_;
};
template<typename... Ts> class LvglAction : public Action<Ts...>, public Parented<LvglComponent> { template<typename... Ts> class LvglAction : public Action<Ts...>, public Parented<LvglComponent> {
public: public:
explicit LvglAction(std::function<void(LvglComponent *)> &&lamb) : action_(std::move(lamb)) {} explicit LvglAction(std::function<void(LvglComponent *)> &&lamb) : action_(std::move(lamb)) {}

View File

@@ -3,6 +3,7 @@ import sys
from esphome import automation, codegen as cg from esphome import automation, codegen as cg
from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj, MockObjClass from esphome.cpp_generator import MockObj, MockObjClass
from esphome.cpp_types import esphome_ns
from .defines import lvgl_ns from .defines import lvgl_ns
from .lvcode import lv_expr from .lvcode import lv_expr
@@ -42,8 +43,11 @@ lv_event_code_t = cg.global_ns.enum("lv_event_code_t")
lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t")
lv_key_t = cg.global_ns.enum("lv_key_t") lv_key_t = cg.global_ns.enum("lv_key_t")
FontEngine = lvgl_ns.class_("FontEngine") FontEngine = lvgl_ns.class_("FontEngine")
PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
DrawEndTrigger = esphome_ns.class_(
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)
)
IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template())
PauseTrigger = lvgl_ns.class_("PauseTrigger", automation.Trigger.template())
ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action)
LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition)
LvglAction = lvgl_ns.class_("LvglAction", automation.Action) LvglAction = lvgl_ns.class_("LvglAction", automation.Action)

View File

@@ -55,6 +55,7 @@ CONFIG_SCHEMA = cv.Schema(
esp32=False, esp32=False,
rp2040=False, rp2040=False,
bk72xx=False, bk72xx=False,
host=False,
): cv.All( ): cv.All(
cv.boolean, cv.boolean,
cv.Any( cv.Any(
@@ -64,6 +65,7 @@ CONFIG_SCHEMA = cv.Schema(
esp8266_arduino=cv.Version(0, 0, 0), esp8266_arduino=cv.Version(0, 0, 0),
rp2040_arduino=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0),
bk72xx_arduino=cv.Version(1, 7, 0), bk72xx_arduino=cv.Version(1, 7, 0),
host=cv.Version(0, 0, 0),
), ),
cv.boolean_false, cv.boolean_false,
), ),

View File

@@ -323,6 +323,8 @@ void Nextion::loop() {
this->set_touch_sleep_timeout(this->touch_sleep_timeout_); this->set_touch_sleep_timeout(this->touch_sleep_timeout_);
} }
this->set_auto_wake_on_touch(this->connection_state_.auto_wake_on_touch_);
this->connection_state_.ignore_is_setup_ = false; this->connection_state_.ignore_is_setup_ = false;
} }

View File

@@ -290,6 +290,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = ble_device.address address = ble_device.address
else: else:
return True return True
if is_mac_address(address): if is_mac_address(address):
asyncio.run(logger_connect(address)) asyncio.run(logger_connect(address))
return True return True

View File

@@ -33,19 +33,13 @@ Message Format:
class ABBWelcomeData { class ABBWelcomeData {
public: public:
// Make default // Make default
ABBWelcomeData() { ABBWelcomeData() : data_{0x55, 0xff} {}
std::fill(std::begin(this->data_), std::end(this->data_), 0);
this->data_[0] = 0x55;
this->data_[1] = 0xff;
}
// Make from initializer_list // Make from initializer_list
ABBWelcomeData(std::initializer_list<uint8_t> data) { ABBWelcomeData(std::initializer_list<uint8_t> data) : data_{} {
std::fill(std::begin(this->data_), std::end(this->data_), 0);
std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin());
} }
// Make from vector // Make from vector
ABBWelcomeData(const std::vector<uint8_t> &data) { ABBWelcomeData(const std::vector<uint8_t> &data) : data_{} {
std::fill(std::begin(this->data_), std::end(this->data_), 0);
std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin());
} }
// Default copy constructor // Default copy constructor

View File

@@ -2,6 +2,7 @@
#include <memory> #include <memory>
#include <tuple> #include <tuple>
#include <forward_list>
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
@@ -264,10 +265,22 @@ template<class C, typename... Ts> class IsRunningCondition : public Condition<Ts
C *parent_; C *parent_;
}; };
/** Wait for a script to finish before continuing.
*
* Uses queue-based storage to safely handle concurrent executions.
* While concurrent execution from the same trigger is uncommon, it's possible
* (e.g., rapid button presses, high-frequency sensor updates), so we use
* queue-based storage for correctness.
*/
template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>, public Component { template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>, public Component {
public: public:
ScriptWaitAction(C *script) : script_(script) {} ScriptWaitAction(C *script) : script_(script) {}
void setup() override {
// Start with loop disabled - only enable when there's work to do
this->disable_loop();
}
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
this->num_running_++; this->num_running_++;
// Check if we can continue immediately. // Check if we can continue immediately.
@@ -275,7 +288,11 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
this->play_next_(x...); this->play_next_(x...);
return; return;
} }
this->var_ = std::make_tuple(x...);
// Store parameters for later execution
this->param_queue_.emplace_front(x...);
// Enable loop now that we have work to do
this->enable_loop();
this->loop(); this->loop();
} }
@@ -286,15 +303,30 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
if (this->script_->is_running()) if (this->script_->is_running())
return; return;
this->play_next_tuple_(this->var_); while (!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();
}
// Queue is now empty - disable loop until next play_complex
this->disable_loop();
} }
void play(Ts... x) override { /* ignore - see play_complex */ void play(Ts... x) override { /* ignore - see play_complex */
} }
void stop() override {
this->param_queue_.clear();
this->disable_loop();
}
protected: protected:
template<int... S> void play_next_tuple_(const std::tuple<Ts...> &tuple, seq<S...> /*unused*/) {
this->play_next_(std::get<S>(tuple)...);
}
C *script_; C *script_;
std::tuple<Ts...> var_{}; std::forward_list<std::tuple<Ts...>> param_queue_;
}; };
} // namespace script } // namespace script

View File

@@ -12,241 +12,256 @@ CODEOWNERS = ["@bdm310"]
STATE_ARG = "state" STATE_ARG = "state"
SDL_KEYMAP = { SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode")
"SDLK_UNKNOWN": 0,
"SDLK_FIRST": 0, SDL_KEYS = (
"SDLK_BACKSPACE": 8, "SDLK_UNKNOWN",
"SDLK_TAB": 9, "SDLK_RETURN",
"SDLK_CLEAR": 12, "SDLK_ESCAPE",
"SDLK_RETURN": 13, "SDLK_BACKSPACE",
"SDLK_PAUSE": 19, "SDLK_TAB",
"SDLK_ESCAPE": 27, "SDLK_SPACE",
"SDLK_SPACE": 32, "SDLK_EXCLAIM",
"SDLK_EXCLAIM": 33, "SDLK_QUOTEDBL",
"SDLK_QUOTEDBL": 34, "SDLK_HASH",
"SDLK_HASH": 35, "SDLK_PERCENT",
"SDLK_DOLLAR": 36, "SDLK_DOLLAR",
"SDLK_AMPERSAND": 38, "SDLK_AMPERSAND",
"SDLK_QUOTE": 39, "SDLK_QUOTE",
"SDLK_LEFTPAREN": 40, "SDLK_LEFTPAREN",
"SDLK_RIGHTPAREN": 41, "SDLK_RIGHTPAREN",
"SDLK_ASTERISK": 42, "SDLK_ASTERISK",
"SDLK_PLUS": 43, "SDLK_PLUS",
"SDLK_COMMA": 44, "SDLK_COMMA",
"SDLK_MINUS": 45, "SDLK_MINUS",
"SDLK_PERIOD": 46, "SDLK_PERIOD",
"SDLK_SLASH": 47, "SDLK_SLASH",
"SDLK_0": 48, "SDLK_0",
"SDLK_1": 49, "SDLK_1",
"SDLK_2": 50, "SDLK_2",
"SDLK_3": 51, "SDLK_3",
"SDLK_4": 52, "SDLK_4",
"SDLK_5": 53, "SDLK_5",
"SDLK_6": 54, "SDLK_6",
"SDLK_7": 55, "SDLK_7",
"SDLK_8": 56, "SDLK_8",
"SDLK_9": 57, "SDLK_9",
"SDLK_COLON": 58, "SDLK_COLON",
"SDLK_SEMICOLON": 59, "SDLK_SEMICOLON",
"SDLK_LESS": 60, "SDLK_LESS",
"SDLK_EQUALS": 61, "SDLK_EQUALS",
"SDLK_GREATER": 62, "SDLK_GREATER",
"SDLK_QUESTION": 63, "SDLK_QUESTION",
"SDLK_AT": 64, "SDLK_AT",
"SDLK_LEFTBRACKET": 91, "SDLK_LEFTBRACKET",
"SDLK_BACKSLASH": 92, "SDLK_BACKSLASH",
"SDLK_RIGHTBRACKET": 93, "SDLK_RIGHTBRACKET",
"SDLK_CARET": 94, "SDLK_CARET",
"SDLK_UNDERSCORE": 95, "SDLK_UNDERSCORE",
"SDLK_BACKQUOTE": 96, "SDLK_BACKQUOTE",
"SDLK_a": 97, "SDLK_a",
"SDLK_b": 98, "SDLK_b",
"SDLK_c": 99, "SDLK_c",
"SDLK_d": 100, "SDLK_d",
"SDLK_e": 101, "SDLK_e",
"SDLK_f": 102, "SDLK_f",
"SDLK_g": 103, "SDLK_g",
"SDLK_h": 104, "SDLK_h",
"SDLK_i": 105, "SDLK_i",
"SDLK_j": 106, "SDLK_j",
"SDLK_k": 107, "SDLK_k",
"SDLK_l": 108, "SDLK_l",
"SDLK_m": 109, "SDLK_m",
"SDLK_n": 110, "SDLK_n",
"SDLK_o": 111, "SDLK_o",
"SDLK_p": 112, "SDLK_p",
"SDLK_q": 113, "SDLK_q",
"SDLK_r": 114, "SDLK_r",
"SDLK_s": 115, "SDLK_s",
"SDLK_t": 116, "SDLK_t",
"SDLK_u": 117, "SDLK_u",
"SDLK_v": 118, "SDLK_v",
"SDLK_w": 119, "SDLK_w",
"SDLK_x": 120, "SDLK_x",
"SDLK_y": 121, "SDLK_y",
"SDLK_z": 122, "SDLK_z",
"SDLK_DELETE": 127, "SDLK_CAPSLOCK",
"SDLK_WORLD_0": 160, "SDLK_F1",
"SDLK_WORLD_1": 161, "SDLK_F2",
"SDLK_WORLD_2": 162, "SDLK_F3",
"SDLK_WORLD_3": 163, "SDLK_F4",
"SDLK_WORLD_4": 164, "SDLK_F5",
"SDLK_WORLD_5": 165, "SDLK_F6",
"SDLK_WORLD_6": 166, "SDLK_F7",
"SDLK_WORLD_7": 167, "SDLK_F8",
"SDLK_WORLD_8": 168, "SDLK_F9",
"SDLK_WORLD_9": 169, "SDLK_F10",
"SDLK_WORLD_10": 170, "SDLK_F11",
"SDLK_WORLD_11": 171, "SDLK_F12",
"SDLK_WORLD_12": 172, "SDLK_PRINTSCREEN",
"SDLK_WORLD_13": 173, "SDLK_SCROLLLOCK",
"SDLK_WORLD_14": 174, "SDLK_PAUSE",
"SDLK_WORLD_15": 175, "SDLK_INSERT",
"SDLK_WORLD_16": 176, "SDLK_HOME",
"SDLK_WORLD_17": 177, "SDLK_PAGEUP",
"SDLK_WORLD_18": 178, "SDLK_DELETE",
"SDLK_WORLD_19": 179, "SDLK_END",
"SDLK_WORLD_20": 180, "SDLK_PAGEDOWN",
"SDLK_WORLD_21": 181, "SDLK_RIGHT",
"SDLK_WORLD_22": 182, "SDLK_LEFT",
"SDLK_WORLD_23": 183, "SDLK_DOWN",
"SDLK_WORLD_24": 184, "SDLK_UP",
"SDLK_WORLD_25": 185, "SDLK_NUMLOCKCLEAR",
"SDLK_WORLD_26": 186, "SDLK_KP_DIVIDE",
"SDLK_WORLD_27": 187, "SDLK_KP_MULTIPLY",
"SDLK_WORLD_28": 188, "SDLK_KP_MINUS",
"SDLK_WORLD_29": 189, "SDLK_KP_PLUS",
"SDLK_WORLD_30": 190, "SDLK_KP_ENTER",
"SDLK_WORLD_31": 191, "SDLK_KP_1",
"SDLK_WORLD_32": 192, "SDLK_KP_2",
"SDLK_WORLD_33": 193, "SDLK_KP_3",
"SDLK_WORLD_34": 194, "SDLK_KP_4",
"SDLK_WORLD_35": 195, "SDLK_KP_5",
"SDLK_WORLD_36": 196, "SDLK_KP_6",
"SDLK_WORLD_37": 197, "SDLK_KP_7",
"SDLK_WORLD_38": 198, "SDLK_KP_8",
"SDLK_WORLD_39": 199, "SDLK_KP_9",
"SDLK_WORLD_40": 200, "SDLK_KP_0",
"SDLK_WORLD_41": 201, "SDLK_KP_PERIOD",
"SDLK_WORLD_42": 202, "SDLK_APPLICATION",
"SDLK_WORLD_43": 203, "SDLK_POWER",
"SDLK_WORLD_44": 204, "SDLK_KP_EQUALS",
"SDLK_WORLD_45": 205, "SDLK_F13",
"SDLK_WORLD_46": 206, "SDLK_F14",
"SDLK_WORLD_47": 207, "SDLK_F15",
"SDLK_WORLD_48": 208, "SDLK_F16",
"SDLK_WORLD_49": 209, "SDLK_F17",
"SDLK_WORLD_50": 210, "SDLK_F18",
"SDLK_WORLD_51": 211, "SDLK_F19",
"SDLK_WORLD_52": 212, "SDLK_F20",
"SDLK_WORLD_53": 213, "SDLK_F21",
"SDLK_WORLD_54": 214, "SDLK_F22",
"SDLK_WORLD_55": 215, "SDLK_F23",
"SDLK_WORLD_56": 216, "SDLK_F24",
"SDLK_WORLD_57": 217, "SDLK_EXECUTE",
"SDLK_WORLD_58": 218, "SDLK_HELP",
"SDLK_WORLD_59": 219, "SDLK_MENU",
"SDLK_WORLD_60": 220, "SDLK_SELECT",
"SDLK_WORLD_61": 221, "SDLK_STOP",
"SDLK_WORLD_62": 222, "SDLK_AGAIN",
"SDLK_WORLD_63": 223, "SDLK_UNDO",
"SDLK_WORLD_64": 224, "SDLK_CUT",
"SDLK_WORLD_65": 225, "SDLK_COPY",
"SDLK_WORLD_66": 226, "SDLK_PASTE",
"SDLK_WORLD_67": 227, "SDLK_FIND",
"SDLK_WORLD_68": 228, "SDLK_MUTE",
"SDLK_WORLD_69": 229, "SDLK_VOLUMEUP",
"SDLK_WORLD_70": 230, "SDLK_VOLUMEDOWN",
"SDLK_WORLD_71": 231, "SDLK_KP_COMMA",
"SDLK_WORLD_72": 232, "SDLK_KP_EQUALSAS400",
"SDLK_WORLD_73": 233, "SDLK_ALTERASE",
"SDLK_WORLD_74": 234, "SDLK_SYSREQ",
"SDLK_WORLD_75": 235, "SDLK_CANCEL",
"SDLK_WORLD_76": 236, "SDLK_CLEAR",
"SDLK_WORLD_77": 237, "SDLK_PRIOR",
"SDLK_WORLD_78": 238, "SDLK_RETURN2",
"SDLK_WORLD_79": 239, "SDLK_SEPARATOR",
"SDLK_WORLD_80": 240, "SDLK_OUT",
"SDLK_WORLD_81": 241, "SDLK_OPER",
"SDLK_WORLD_82": 242, "SDLK_CLEARAGAIN",
"SDLK_WORLD_83": 243, "SDLK_CRSEL",
"SDLK_WORLD_84": 244, "SDLK_EXSEL",
"SDLK_WORLD_85": 245, "SDLK_KP_00",
"SDLK_WORLD_86": 246, "SDLK_KP_000",
"SDLK_WORLD_87": 247, "SDLK_THOUSANDSSEPARATOR",
"SDLK_WORLD_88": 248, "SDLK_DECIMALSEPARATOR",
"SDLK_WORLD_89": 249, "SDLK_CURRENCYUNIT",
"SDLK_WORLD_90": 250, "SDLK_CURRENCYSUBUNIT",
"SDLK_WORLD_91": 251, "SDLK_KP_LEFTPAREN",
"SDLK_WORLD_92": 252, "SDLK_KP_RIGHTPAREN",
"SDLK_WORLD_93": 253, "SDLK_KP_LEFTBRACE",
"SDLK_WORLD_94": 254, "SDLK_KP_RIGHTBRACE",
"SDLK_WORLD_95": 255, "SDLK_KP_TAB",
"SDLK_KP0": 256, "SDLK_KP_BACKSPACE",
"SDLK_KP1": 257, "SDLK_KP_A",
"SDLK_KP2": 258, "SDLK_KP_B",
"SDLK_KP3": 259, "SDLK_KP_C",
"SDLK_KP4": 260, "SDLK_KP_D",
"SDLK_KP5": 261, "SDLK_KP_E",
"SDLK_KP6": 262, "SDLK_KP_F",
"SDLK_KP7": 263, "SDLK_KP_XOR",
"SDLK_KP8": 264, "SDLK_KP_POWER",
"SDLK_KP9": 265, "SDLK_KP_PERCENT",
"SDLK_KP_PERIOD": 266, "SDLK_KP_LESS",
"SDLK_KP_DIVIDE": 267, "SDLK_KP_GREATER",
"SDLK_KP_MULTIPLY": 268, "SDLK_KP_AMPERSAND",
"SDLK_KP_MINUS": 269, "SDLK_KP_DBLAMPERSAND",
"SDLK_KP_PLUS": 270, "SDLK_KP_VERTICALBAR",
"SDLK_KP_ENTER": 271, "SDLK_KP_DBLVERTICALBAR",
"SDLK_KP_EQUALS": 272, "SDLK_KP_COLON",
"SDLK_UP": 273, "SDLK_KP_HASH",
"SDLK_DOWN": 274, "SDLK_KP_SPACE",
"SDLK_RIGHT": 275, "SDLK_KP_AT",
"SDLK_LEFT": 276, "SDLK_KP_EXCLAM",
"SDLK_INSERT": 277, "SDLK_KP_MEMSTORE",
"SDLK_HOME": 278, "SDLK_KP_MEMRECALL",
"SDLK_END": 279, "SDLK_KP_MEMCLEAR",
"SDLK_PAGEUP": 280, "SDLK_KP_MEMADD",
"SDLK_PAGEDOWN": 281, "SDLK_KP_MEMSUBTRACT",
"SDLK_F1": 282, "SDLK_KP_MEMMULTIPLY",
"SDLK_F2": 283, "SDLK_KP_MEMDIVIDE",
"SDLK_F3": 284, "SDLK_KP_PLUSMINUS",
"SDLK_F4": 285, "SDLK_KP_CLEAR",
"SDLK_F5": 286, "SDLK_KP_CLEARENTRY",
"SDLK_F6": 287, "SDLK_KP_BINARY",
"SDLK_F7": 288, "SDLK_KP_OCTAL",
"SDLK_F8": 289, "SDLK_KP_DECIMAL",
"SDLK_F9": 290, "SDLK_KP_HEXADECIMAL",
"SDLK_F10": 291, "SDLK_LCTRL",
"SDLK_F11": 292, "SDLK_LSHIFT",
"SDLK_F12": 293, "SDLK_LALT",
"SDLK_F13": 294, "SDLK_LGUI",
"SDLK_F14": 295, "SDLK_RCTRL",
"SDLK_F15": 296, "SDLK_RSHIFT",
"SDLK_NUMLOCK": 300, "SDLK_RALT",
"SDLK_CAPSLOCK": 301, "SDLK_RGUI",
"SDLK_SCROLLOCK": 302, "SDLK_MODE",
"SDLK_RSHIFT": 303, "SDLK_AUDIONEXT",
"SDLK_LSHIFT": 304, "SDLK_AUDIOPREV",
"SDLK_RCTRL": 305, "SDLK_AUDIOSTOP",
"SDLK_LCTRL": 306, "SDLK_AUDIOPLAY",
"SDLK_RALT": 307, "SDLK_AUDIOMUTE",
"SDLK_LALT": 308, "SDLK_MEDIASELECT",
"SDLK_RMETA": 309, "SDLK_WWW",
"SDLK_LMETA": 310, "SDLK_MAIL",
"SDLK_LSUPER": 311, "SDLK_CALCULATOR",
"SDLK_RSUPER": 312, "SDLK_COMPUTER",
"SDLK_MODE": 313, "SDLK_AC_SEARCH",
"SDLK_COMPOSE": 314, "SDLK_AC_HOME",
"SDLK_HELP": 315, "SDLK_AC_BACK",
"SDLK_PRINT": 316, "SDLK_AC_FORWARD",
"SDLK_SYSREQ": 317, "SDLK_AC_STOP",
"SDLK_BREAK": 318, "SDLK_AC_REFRESH",
"SDLK_MENU": 319, "SDLK_AC_BOOKMARKS",
"SDLK_POWER": 320, "SDLK_BRIGHTNESSDOWN",
"SDLK_EURO": 321, "SDLK_BRIGHTNESSUP",
"SDLK_UNDO": 322, "SDLK_DISPLAYSWITCH",
} "SDLK_KBDILLUMTOGGLE",
"SDLK_KBDILLUMDOWN",
"SDLK_KBDILLUMUP",
"SDLK_EJECT",
"SDLK_SLEEP",
"SDLK_APP1",
"SDLK_APP2",
"SDLK_AUDIOREWIND",
"SDLK_AUDIOFASTFORWARD",
"SDLK_SOFTLEFT",
"SDLK_SOFTRIGHT",
"SDLK_CALL",
"SDLK_ENDCALL",
)
SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS}
CONFIG_SCHEMA = ( CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(BinarySensor) binary_sensor.binary_sensor_schema(BinarySensor)

View File

@@ -1,7 +1,5 @@
#pragma once #pragma once
#include <set>
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/components/output/binary_output.h" #include "esphome/components/output/binary_output.h"
#include "esphome/components/output/float_output.h" #include "esphome/components/output/float_output.h"
@@ -18,7 +16,7 @@ class SpeedFan : public Component, public fan::Fan {
void set_output(output::FloatOutput *output) { this->output_ = output; } void set_output(output::FloatOutput *output) { this->output_ = output; }
void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; }
void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; }
void set_preset_modes(const std::set<std::string> &presets) { this->preset_modes_ = presets; } void set_preset_modes(std::initializer_list<const char *> presets) { this->preset_modes_ = presets; }
fan::FanTraits get_traits() override { return this->traits_; } fan::FanTraits get_traits() override { return this->traits_; }
protected: protected:
@@ -30,7 +28,7 @@ class SpeedFan : public Component, public fan::Fan {
output::BinaryOutput *direction_{nullptr}; output::BinaryOutput *direction_{nullptr};
int speed_count_{}; int speed_count_{};
fan::FanTraits traits_; fan::FanTraits traits_;
std::set<std::string> preset_modes_{}; std::vector<const char *> preset_modes_{};
}; };
} // namespace speed } // namespace speed

View File

@@ -138,6 +138,7 @@ def _concat_nodes_override(values: Iterator[Any]) -> Any:
values = chain(head, values) values = chain(head, values)
raw = "".join([str(v) for v in values]) raw = "".join([str(v) for v in values])
result = None
try: try:
# Attempt to parse the concatenated string into a Python literal. # Attempt to parse the concatenated string into a Python literal.
# This allows expressions like "1 + 2" to be evaluated to the integer 3. # This allows expressions like "1 + 2" to be evaluated to the integer 3.
@@ -145,11 +146,16 @@ def _concat_nodes_override(values: Iterator[Any]) -> Any:
# fall back to returning the raw string. This is consistent with # fall back to returning the raw string. This is consistent with
# Home Assistant's behavior when evaluating templates # Home Assistant's behavior when evaluating templates
result = literal_eval(raw) result = literal_eval(raw)
except (ValueError, SyntaxError, MemoryError, TypeError):
pass
else:
if isinstance(result, set):
# Sets are not supported, return raw string
return raw
if not isinstance(result, str): if not isinstance(result, str):
return result return result
except (ValueError, SyntaxError, MemoryError, TypeError):
pass
return raw return raw

View File

@@ -1,7 +1,5 @@
#pragma once #pragma once
#include <set>
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/components/fan/fan.h" #include "esphome/components/fan/fan.h"
@@ -16,7 +14,7 @@ class TemplateFan : public Component, public fan::Fan {
void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; }
void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; }
void set_speed_count(int count) { this->speed_count_ = count; } void set_speed_count(int count) { this->speed_count_ = count; }
void set_preset_modes(const std::set<std::string> &presets) { this->preset_modes_ = presets; } void set_preset_modes(std::initializer_list<const char *> presets) { this->preset_modes_ = presets; }
fan::FanTraits get_traits() override { return this->traits_; } fan::FanTraits get_traits() override { return this->traits_; }
protected: protected:
@@ -26,7 +24,7 @@ class TemplateFan : public Component, public fan::Fan {
bool has_direction_{false}; bool has_direction_{false};
int speed_count_{0}; int speed_count_{0};
fan::FanTraits traits_; fan::FanTraits traits_;
std::set<std::string> preset_modes_{}; std::vector<const char *> preset_modes_{};
}; };
} // namespace template_ } // namespace template_

View File

@@ -111,8 +111,7 @@ void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size
for (auto &event : this->deferred_queue_) { for (auto &event : this->deferred_queue_) {
if (event == item) { if (event == item) {
event = item; return; // Already in queue, no need to update since items are equal
return;
} }
} }
this->deferred_queue_.push_back(item); this->deferred_queue_.push_back(item);
@@ -220,18 +219,16 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer
DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events");
this->push_back(es); this->push_back(es);
es->onConnect([this, ws, es](AsyncEventSourceClient *client) { es->onConnect([this, es](AsyncEventSourceClient *client) { this->on_client_connect_(es); });
ws->defer([this, ws, es]() { this->on_client_connect_(ws, es); });
});
es->onDisconnect([this, ws, es](AsyncEventSourceClient *client) { es->onDisconnect([this, es](AsyncEventSourceClient *client) { this->on_client_disconnect_(es); });
ws->defer([this, es]() { this->on_client_disconnect_((DeferredUpdateEventSource *) es); });
});
es->handleRequest(request); es->handleRequest(request);
} }
void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUpdateEventSource *source) { void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource *source) {
WebServer *ws = source->web_server_;
ws->defer([ws, source]() {
// Configure reconnect timeout and send config // Configure reconnect timeout and send config
// this should always go through since the AsyncEventSourceClient event queue is empty on connect // this should always go through since the AsyncEventSourceClient event queue is empty on connect
std::string message = ws->get_config_json(); std::string message = ws->get_config_json();
@@ -257,13 +254,16 @@ void DeferredUpdateEventSourceList::on_client_connect_(WebServer *ws, DeferredUp
// while(!source->entities_iterator_.completed()) { // while(!source->entities_iterator_.completed()) {
// source->entities_iterator_.advance(); // source->entities_iterator_.advance();
//} //}
});
} }
void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSource *source) { void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSource *source) {
source->web_server_->defer([this, source]() {
// This method was called via WebServer->defer() and is no longer executing in the // This method was called via WebServer->defer() and is no longer executing in the
// context of the network callback. The object is now dead and can be safely deleted. // context of the network callback. The object is now dead and can be safely deleted.
this->remove(source); this->remove(source);
delete source; // NOLINT delete source; // NOLINT
});
} }
#endif #endif
@@ -435,9 +435,10 @@ void WebServer::on_sensor_update(sensor::Sensor *obj, float state) {
} }
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (sensor::Sensor *obj : App.get_sensors()) { for (sensor::Sensor *obj : App.get_sensors()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { // Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
std::string data = this->sensor_json(obj, obj->state, detail); std::string data = this->sensor_json(obj, obj->state, detail);
request->send(200, "application/json", data.c_str()); request->send(200, "application/json", data.c_str());
@@ -477,9 +478,10 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj, const std::s
} }
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (text_sensor::TextSensor *obj : App.get_text_sensors()) { for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { // Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
std::string data = this->text_sensor_json(obj, obj->state, detail); std::string data = this->text_sensor_json(obj, obj->state, detail);
request->send(200, "application/json", data.c_str()); request->send(200, "application/json", data.c_str());
@@ -516,7 +518,7 @@ void WebServer::on_switch_update(switch_::Switch *obj, bool state) {
} }
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (switch_::Switch *obj : App.get_switches()) { for (switch_::Switch *obj : App.get_switches()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -585,7 +587,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail
#ifdef USE_BUTTON #ifdef USE_BUTTON
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (button::Button *obj : App.get_buttons()) { for (button::Button *obj : App.get_buttons()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
@@ -627,9 +629,10 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
} }
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) { for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { // Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
std::string data = this->binary_sensor_json(obj, obj->state, detail); std::string data = this->binary_sensor_json(obj, obj->state, detail);
request->send(200, "application/json", data.c_str()); request->send(200, "application/json", data.c_str());
@@ -665,7 +668,7 @@ void WebServer::on_fan_update(fan::Fan *obj) {
} }
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (fan::Fan *obj : App.get_fans()) { for (fan::Fan *obj : App.get_fans()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -739,7 +742,7 @@ void WebServer::on_light_update(light::LightState *obj) {
} }
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (light::LightState *obj : App.get_lights()) { for (light::LightState *obj : App.get_lights()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -812,7 +815,7 @@ void WebServer::on_cover_update(cover::Cover *obj) {
} }
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (cover::Cover *obj : App.get_covers()) { for (cover::Cover *obj : App.get_covers()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -897,7 +900,7 @@ void WebServer::on_number_update(number::Number *obj, float state) {
} }
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_numbers()) { for (auto *obj : App.get_numbers()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -962,7 +965,7 @@ void WebServer::on_date_update(datetime::DateEntity *obj) {
} }
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_dates()) { for (auto *obj : App.get_dates()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
@@ -1017,7 +1020,7 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) {
} }
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_times()) { for (auto *obj : App.get_times()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
@@ -1071,7 +1074,7 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
} }
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_datetimes()) { for (auto *obj : App.get_datetimes()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
@@ -1126,7 +1129,7 @@ void WebServer::on_text_update(text::Text *obj, const std::string &state) {
} }
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_texts()) { for (auto *obj : App.get_texts()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1180,7 +1183,7 @@ void WebServer::on_select_update(select::Select *obj, const std::string &state,
} }
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_selects()) { for (auto *obj : App.get_selects()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1236,7 +1239,7 @@ void WebServer::on_climate_update(climate::Climate *obj) {
} }
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_climates()) { for (auto *obj : App.get_climates()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1377,7 +1380,7 @@ void WebServer::on_lock_update(lock::Lock *obj) {
} }
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (lock::Lock *obj : App.get_locks()) { for (lock::Lock *obj : App.get_locks()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1448,7 +1451,7 @@ void WebServer::on_valve_update(valve::Valve *obj) {
} }
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (valve::Valve *obj : App.get_valves()) { for (valve::Valve *obj : App.get_valves()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1529,7 +1532,7 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP
} }
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) { for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {
@@ -1608,10 +1611,11 @@ void WebServer::on_event(event::Event *obj, const std::string &event_type) {
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (event::Event *obj : App.get_events()) { for (event::Event *obj : App.get_events()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { // Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
auto detail = get_request_detail(request); auto detail = get_request_detail(request);
std::string data = this->event_json(obj, "", detail); std::string data = this->event_json(obj, "", detail);
request->send(200, "application/json", data.c_str()); request->send(200, "application/json", data.c_str());
@@ -1673,7 +1677,7 @@ void WebServer::on_update(update::UpdateEntity *obj) {
} }
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (update::UpdateEntity *obj : App.get_updates()) { for (update::UpdateEntity *obj : App.get_updates()) {
if (!match.id_equals(obj->get_object_id())) if (!match.id_equals_entity(obj))
continue; continue;
if (request->method() == HTTP_GET && match.method_empty()) { if (request->method() == HTTP_GET && match.method_empty()) {

View File

@@ -48,8 +48,15 @@ struct UrlMatch {
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0; return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
} }
bool id_equals(const std::string &str) const { bool id_equals_entity(EntityBase *entity) const {
return id && id_len == str.length() && memcmp(id, str.c_str(), id_len) == 0; // Zero-copy comparison using StringRef
StringRef static_ref = entity->get_object_id_ref_for_api_();
if (!static_ref.empty()) {
return id && id_len == static_ref.size() && memcmp(id, static_ref.c_str(), id_len) == 0;
}
// Fallback to allocation (rare)
const auto &obj_id = entity->get_object_id();
return id && id_len == obj_id.length() && memcmp(id, obj_id.c_str(), id_len) == 0;
} }
bool method_equals(const char *str) const { bool method_equals(const char *str) const {
@@ -141,7 +148,7 @@ class DeferredUpdateEventSource : public AsyncEventSource {
class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource *> { class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource *> {
protected: protected:
void on_client_connect_(WebServer *ws, DeferredUpdateEventSource *source); void on_client_connect_(DeferredUpdateEventSource *source);
void on_client_disconnect_(DeferredUpdateEventSource *source); void on_client_disconnect_(DeferredUpdateEventSource *source);
public: public:

View File

@@ -4,6 +4,7 @@
#include <memory> #include <memory>
#include <cstring> #include <cstring>
#include <cctype> #include <cctype>
#include <cinttypes>
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -245,8 +246,8 @@ void AsyncWebServerRequest::redirect(const std::string &url) {
} }
void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type) { void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type) {
// Set status code - use constants for common codes to avoid string allocation // Set status code - use constants for common codes, default to 500 for unknown codes
const char *status = nullptr; const char *status;
switch (code) { switch (code) {
case 200: case 200:
status = HTTPD_200; status = HTTPD_200;
@@ -258,9 +259,10 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
status = HTTPD_409; status = HTTPD_409;
break; break;
default: default:
status = HTTPD_500;
break; break;
} }
httpd_resp_set_status(*this, status == nullptr ? to_string(code).c_str() : status); httpd_resp_set_status(*this, status);
if (content_type && *content_type) { if (content_type && *content_type) {
httpd_resp_set_type(*this, content_type); httpd_resp_set_type(*this, content_type);
@@ -348,7 +350,13 @@ void AsyncWebServerResponse::addHeader(const char *name, const char *value) {
httpd_resp_set_hdr(*this->req_, name, value); httpd_resp_set_hdr(*this->req_, name, value);
} }
void AsyncResponseStream::print(float value) { this->print(to_string(value)); } void AsyncResponseStream::print(float value) {
// Use stack buffer to avoid temporary string allocation
// Size: sign (1) + digits (10) + decimal (1) + precision (6) + exponent (5) + null (1) = 24, use 32 for safety
char buf[32];
int len = snprintf(buf, sizeof(buf), "%f", value);
this->content_.append(buf, len);
}
void AsyncResponseStream::printf(const char *fmt, ...) { void AsyncResponseStream::printf(const char *fmt, ...) {
va_list args; va_list args;
@@ -494,8 +502,7 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size // Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size
for (auto &event : this->deferred_queue_) { for (auto &event : this->deferred_queue_) {
if (event == item) { if (event == item) {
event = item; return; // Already in queue, no need to update since items are equal
return;
} }
} }
this->deferred_queue_.push_back(item); this->deferred_queue_.push_back(item);
@@ -594,16 +601,19 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char
event_buffer_.append(chunk_len_header); event_buffer_.append(chunk_len_header);
// Use stack buffer for formatting numeric fields to avoid temporary string allocations
// Size: "retry: " (7) + max uint32 (10 digits) + CRLF (2) + null (1) = 20 bytes, use 32 for safety
constexpr size_t num_buf_size = 32;
char num_buf[num_buf_size];
if (reconnect) { if (reconnect) {
event_buffer_.append("retry: ", sizeof("retry: ") - 1); int len = snprintf(num_buf, num_buf_size, "retry: %" PRIu32 CRLF_STR, reconnect);
event_buffer_.append(to_string(reconnect)); event_buffer_.append(num_buf, len);
event_buffer_.append(CRLF_STR, CRLF_LEN);
} }
if (id) { if (id) {
event_buffer_.append("id: ", sizeof("id: ") - 1); int len = snprintf(num_buf, num_buf_size, "id: %" PRIu32 CRLF_STR, id);
event_buffer_.append(to_string(id)); event_buffer_.append(num_buf, len);
event_buffer_.append(CRLF_STR, CRLF_LEN);
} }
if (event && *event) { if (event && *event) {

View File

@@ -3,7 +3,7 @@
#include <zephyr/kernel.h> #include <zephyr/kernel.h>
#include <zephyr/drivers/watchdog.h> #include <zephyr/drivers/watchdog.h>
#include <zephyr/sys/reboot.h> #include <zephyr/sys/reboot.h>
#include <zephyr/random/rand32.h> #include <zephyr/random/random.h>
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"

View File

@@ -8,8 +8,8 @@ namespace zephyr {
static const char *const TAG = "zephyr"; static const char *const TAG = "zephyr";
static int flags_to_mode(gpio::Flags flags, bool inverted, bool value) { static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) {
int ret = 0; gpio_flags_t ret = 0;
if (flags & gpio::FLAG_INPUT) { if (flags & gpio::FLAG_INPUT) {
ret |= GPIO_INPUT; ret |= GPIO_INPUT;
} }
@@ -79,7 +79,10 @@ void ZephyrGPIOPin::pin_mode(gpio::Flags flags) {
if (nullptr == this->gpio_) { if (nullptr == this->gpio_) {
return; return;
} }
gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_)); auto ret = gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_));
if (ret != 0) {
ESP_LOGE(TAG, "gpio %u cannot be configured %d.", this->pin_, ret);
}
} }
std::string ZephyrGPIOPin::dump_summary() const { std::string ZephyrGPIOPin::dump_summary() const {

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -18,6 +19,7 @@ import logging
from pathlib import Path from pathlib import Path
import re import re
from string import ascii_letters, digits from string import ascii_letters, digits
import typing
import uuid as uuid_ import uuid as uuid_
import voluptuous as vol import voluptuous as vol
@@ -1763,16 +1765,37 @@ class SplitDefault(Optional):
class OnlyWith(Optional): class OnlyWith(Optional):
"""Set the default value only if the given component is loaded.""" """Set the default value only if the given component(s) is/are loaded.
def __init__(self, key, component, default=None): This validator allows configuration keys to have defaults that are only applied
when specific component(s) are loaded. Supports both single component names and
lists of components.
Args:
key: Configuration key
component: Single component name (str) or list of component names.
For lists, ALL components must be loaded for the default to apply.
default: Default value to use when condition is met
Example:
# Single component
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(MQTTComponent)
# Multiple components (all must be loaded)
cv.OnlyWith(CONF_ZIGBEE_ID, ["zigbee", "nrf52"]): cv.use_id(Zigbee)
"""
def __init__(self, key, component: str | list[str], default=None) -> None:
super().__init__(key) super().__init__(key)
self._component = component self._component = component
self._default = vol.default_factory(default) self._default = vol.default_factory(default)
@property @property
def default(self): def default(self) -> Callable[[], typing.Any] | vol.Undefined:
if self._component in CORE.loaded_integrations: if isinstance(self._component, list):
if all(c in CORE.loaded_integrations for c in self._component):
return self._default
elif self._component in CORE.loaded_integrations:
return self._default return self._default
return vol.UNDEFINED return vol.UNDEFINED

View File

@@ -576,11 +576,12 @@ void Application::yield_with_select_(uint32_t delay_ms) {
// Update fd_set if socket list has changed // Update fd_set if socket list has changed
if (this->socket_fds_changed_) { if (this->socket_fds_changed_) {
FD_ZERO(&this->base_read_fds_); FD_ZERO(&this->base_read_fds_);
// fd bounds are already validated in register_socket_fd() or guaranteed by platform design:
// - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS)
// - Other platforms: register_socket_fd() validates fd < FD_SETSIZE
for (int fd : this->socket_fds_) { for (int fd : this->socket_fds_) {
if (fd >= 0 && fd < FD_SETSIZE) {
FD_SET(fd, &this->base_read_fds_); FD_SET(fd, &this->base_read_fds_);
} }
}
this->socket_fds_changed_ = false; this->socket_fds_changed_ = false;
} }

View File

@@ -10,6 +10,7 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include <vector> #include <vector>
#include <forward_list>
namespace esphome { namespace esphome {
@@ -102,7 +103,7 @@ template<typename... Ts> class ForCondition : public Condition<Ts...>, public Co
bool check_internal() { bool check_internal() {
bool cond = this->condition_->check(); bool cond = this->condition_->check();
if (!cond) if (!cond)
this->last_inactive_ = millis(); this->last_inactive_ = App.get_loop_component_start_time();
return cond; return cond;
} }
@@ -268,32 +269,28 @@ template<typename... Ts> class WhileAction : public Action<Ts...> {
void add_then(const std::initializer_list<Action<Ts...> *> &actions) { void add_then(const std::initializer_list<Action<Ts...> *> &actions) {
this->then_.add_actions(actions); this->then_.add_actions(actions);
this->then_.add_action(new LambdaAction<Ts...>([this](Ts... x) { this->then_.add_action(new LambdaAction<Ts...>([this](Ts... x) {
if (this->num_running_ > 0 && this->condition_->check_tuple(this->var_)) { if (this->num_running_ > 0 && this->condition_->check(x...)) {
// play again // play again
if (this->num_running_ > 0) { this->then_.play(x...);
this->then_.play_tuple(this->var_);
}
} else { } else {
// condition false, play next // condition false, play next
this->play_next_tuple_(this->var_); this->play_next_(x...);
} }
})); }));
} }
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
this->num_running_++; this->num_running_++;
// Store loop parameters
this->var_ = std::make_tuple(x...);
// Initial condition check // Initial condition check
if (!this->condition_->check_tuple(this->var_)) { if (!this->condition_->check(x...)) {
// If new condition check failed, stop loop if running // If new condition check failed, stop loop if running
this->then_.stop(); this->then_.stop();
this->play_next_tuple_(this->var_); this->play_next_(x...);
return; return;
} }
if (this->num_running_ > 0) { if (this->num_running_ > 0) {
this->then_.play_tuple(this->var_); this->then_.play(x...);
} }
} }
@@ -305,7 +302,6 @@ template<typename... Ts> class WhileAction : public Action<Ts...> {
protected: protected:
Condition<Ts...> *condition_; Condition<Ts...> *condition_;
ActionList<Ts...> then_; ActionList<Ts...> then_;
std::tuple<Ts...> var_{};
}; };
template<typename... Ts> class RepeatAction : public Action<Ts...> { template<typename... Ts> class RepeatAction : public Action<Ts...> {
@@ -317,7 +313,7 @@ template<typename... Ts> class RepeatAction : public Action<Ts...> {
this->then_.add_action(new LambdaAction<uint32_t, Ts...>([this](uint32_t iteration, Ts... x) { this->then_.add_action(new LambdaAction<uint32_t, Ts...>([this](uint32_t iteration, Ts... x) {
iteration++; iteration++;
if (iteration >= this->count_.value(x...)) { if (iteration >= this->count_.value(x...)) {
this->play_next_tuple_(this->var_); this->play_next_(x...);
} else { } else {
this->then_.play(iteration, x...); this->then_.play(iteration, x...);
} }
@@ -326,11 +322,10 @@ template<typename... Ts> class RepeatAction : public Action<Ts...> {
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
this->num_running_++; this->num_running_++;
this->var_ = std::make_tuple(x...);
if (this->count_.value(x...) > 0) { if (this->count_.value(x...) > 0) {
this->then_.play(0, x...); this->then_.play(0, x...);
} else { } else {
this->play_next_tuple_(this->var_); this->play_next_(x...);
} }
} }
@@ -341,15 +336,26 @@ template<typename... Ts> class RepeatAction : public Action<Ts...> {
protected: protected:
ActionList<uint32_t, Ts...> then_; ActionList<uint32_t, Ts...> then_;
std::tuple<Ts...> var_;
}; };
/** Wait until a condition is true to continue execution.
*
* Uses queue-based storage to safely handle concurrent executions.
* While concurrent execution from the same trigger is uncommon, it's possible
* (e.g., rapid button presses, high-frequency sensor updates), so we use
* queue-based storage for correctness.
*/
template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Component { template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Component {
public: public:
WaitUntilAction(Condition<Ts...> *condition) : condition_(condition) {} WaitUntilAction(Condition<Ts...> *condition) : condition_(condition) {}
TEMPLATABLE_VALUE(uint32_t, timeout_value) TEMPLATABLE_VALUE(uint32_t, timeout_value)
void setup() override {
// Start with loop disabled - only enable when there's work to do
this->disable_loop();
}
void play_complex(Ts... x) override { void play_complex(Ts... x) override {
this->num_running_++; this->num_running_++;
// Check if we can continue immediately. // Check if we can continue immediately.
@@ -359,13 +365,14 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
} }
return; return;
} }
this->var_ = std::make_tuple(x...);
if (this->timeout_value_.has_value()) { // Store for later processing
auto f = std::bind(&WaitUntilAction<Ts...>::play_next_, this, x...); auto now = millis();
this->set_timeout("timeout", this->timeout_value_.value(x...), f); auto timeout = this->timeout_value_.optional_value(x...);
} this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...));
// Enable loop now that we have work to do
this->enable_loop();
this->loop(); this->loop();
} }
@@ -373,13 +380,32 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
if (this->num_running_ == 0) if (this->num_running_ == 0)
return; return;
if (!this->condition_->check_tuple(this->var_)) { auto now = App.get_loop_component_start_time();
return;
this->var_queue_.remove_if([&](auto &queued) {
auto start = std::get<uint32_t>(queued);
auto timeout = std::get<optional<uint32_t>>(queued);
auto &var = std::get<std::tuple<Ts...>>(queued);
auto expired = timeout && (now - start) >= *timeout;
if (!expired && !this->condition_->check_tuple(var)) {
return false;
} }
this->cancel_timeout("timeout"); this->play_next_tuple_(var);
return true;
});
this->play_next_tuple_(this->var_); // If queue is now empty, disable loop until next play_complex
if (this->var_queue_.empty()) {
this->disable_loop();
}
}
void stop() override {
this->var_queue_.clear();
this->disable_loop();
} }
float get_setup_priority() const override { return setup_priority::DATA; } float get_setup_priority() const override { return setup_priority::DATA; }
@@ -387,11 +413,9 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
void play(Ts... x) override { /* ignore - see play_complex */ void play(Ts... x) override { /* ignore - see play_complex */
} }
void stop() override { this->cancel_timeout("timeout"); }
protected: protected:
Condition<Ts...> *condition_; Condition<Ts...> *condition_;
std::tuple<Ts...> var_{}; std::forward_list<std::tuple<uint32_t, optional<uint32_t>, std::tuple<Ts...>>> var_queue_{};
}; };
template<typename... Ts> class UpdateComponentAction : public Action<Ts...> { template<typename... Ts> class UpdateComponentAction : public Action<Ts...> {

View File

@@ -284,6 +284,7 @@ bool Component::is_ready() const {
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE || (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE ||
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP; (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP;
} }
bool Component::is_idle() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE; }
bool Component::can_proceed() { return true; } bool Component::can_proceed() { return true; }
bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; } bool Component::status_has_warning() const { return this->component_state_ & STATUS_LED_WARNING; }
bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; } bool Component::status_has_error() const { return this->component_state_ & STATUS_LED_ERROR; }

View File

@@ -141,6 +141,14 @@ class Component {
*/ */
bool is_in_loop_state() const; bool is_in_loop_state() const;
/** Check if this component is idle.
* Being idle means being in LOOP_DONE state.
* This means the component has completed setup, is not failed, but its loop is currently disabled.
*
* @return True if the component is idle
*/
bool is_idle() const;
/** Mark this component as failed. Any future timeouts/intervals/setup/loop will no longer be called. /** Mark this component as failed. Any future timeouts/intervals/setup/loop will no longer be called.
* *
* This might be useful if a component wants to indicate that a connection to its peripheral failed. * This might be useful if a component wants to indicate that a connection to its peripheral failed.

View File

@@ -17,6 +17,10 @@ namespace api {
class APIConnection; class APIConnection;
} // namespace api } // namespace api
namespace web_server {
struct UrlMatch;
} // namespace web_server
enum EntityCategory : uint8_t { enum EntityCategory : uint8_t {
ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_NONE = 0,
ENTITY_CATEGORY_CONFIG = 1, ENTITY_CATEGORY_CONFIG = 1,
@@ -116,6 +120,7 @@ class EntityBase {
protected: protected:
friend class api::APIConnection; friend class api::APIConnection;
friend struct web_server::UrlMatch;
// Get object_id as StringRef when it's static (for API usage) // Get object_id as StringRef when it's static (for API usage)
// Returns empty StringRef if object_id is dynamic (needs allocation) // Returns empty StringRef if object_id is dynamic (needs allocation)

View File

@@ -316,59 +316,37 @@ optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
return 0; return 0;
return next_exec - now_64; return next_exec - now_64;
} }
void HOT Scheduler::call(uint32_t now) {
#ifndef ESPHOME_THREAD_SINGLE
// Process defer queue first to guarantee FIFO execution order for deferred items.
// Previously, defer() used the heap which gave undefined order for equal timestamps,
// causing race conditions on multi-core systems (ESP32, BK7200).
// With the defer queue:
// - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_
// - Items execute in exact order they were deferred (FIFO guarantee)
// - No deferred items exist in to_add_, so processing order doesn't affect correctness
// Single-core platforms don't use this queue and fall back to the heap-based approach.
//
// Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still
// processed here. They are skipped during execution by should_skip_item_().
// This is intentional - no memory leak occurs.
//
// We use an index (defer_queue_front_) to track the read position instead of calling
// erase() on every pop, which would be O(n). The queue is processed once per loop -
// any items added during processing are left for the next loop iteration.
// Snapshot the queue end point - only process items that existed at loop start void Scheduler::full_cleanup_removed_items_() {
// Items added during processing (by callbacks or other threads) run next loop // We hold the lock for the entire cleanup operation because:
// No lock needed: single consumer (main loop), stale read just means we process less this iteration // 1. We're rebuilding the entire items_ list, so we need exclusive access throughout
size_t defer_queue_end = this->defer_queue_.size(); // 2. Other threads must see either the old state or the new state, not intermediate states
// 3. The operation is already expensive (O(n)), so lock overhead is negligible
// 4. No operations inside can block or take other locks, so no deadlock risk
LockGuard guard{this->lock_};
while (this->defer_queue_front_ < defer_queue_end) { std::vector<std::unique_ptr<SchedulerItem>> valid_items;
std::unique_ptr<SchedulerItem> item;
{
LockGuard lock(this->lock_);
// SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_.
// This is intentional and safe because:
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_
// and has_cancelled_timeout_in_container_ in scheduler.h)
// 3. The lock protects concurrent access, but the nullptr remains until cleanup
item = std::move(this->defer_queue_[this->defer_queue_front_]);
this->defer_queue_front_++;
}
// Execute callback without holding lock to prevent deadlocks // Move all non-removed items to valid_items, recycle removed ones
// if the callback tries to call defer() again for (auto &item : this->items_) {
if (!this->should_skip_item_(item.get())) { if (!is_item_removed_(item.get())) {
now = this->execute_item_(item.get(), now); valid_items.push_back(std::move(item));
} } else {
// Recycle the defer item after execution // Recycle removed items
this->recycle_item_(std::move(item)); this->recycle_item_(std::move(item));
} }
// If we've consumed all items up to the snapshot point, clean up the dead space
// Single consumer (main loop), so no lock needed for this check
if (this->defer_queue_front_ >= defer_queue_end) {
LockGuard lock(this->lock_);
this->cleanup_defer_queue_locked_();
} }
// Replace items_ with the filtered list
this->items_ = std::move(valid_items);
// Rebuild the heap structure since items are no longer in heap order
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
this->to_remove_ = 0;
}
void HOT Scheduler::call(uint32_t now) {
#ifndef ESPHOME_THREAD_SINGLE
this->process_defer_queue_(now);
#endif /* not ESPHOME_THREAD_SINGLE */ #endif /* not ESPHOME_THREAD_SINGLE */
// Convert the fresh timestamp from main loop to 64-bit for scheduler operations // Convert the fresh timestamp from main loop to 64-bit for scheduler operations
@@ -429,30 +407,7 @@ void HOT Scheduler::call(uint32_t now) {
// If we still have too many cancelled items, do a full cleanup // If we still have too many cancelled items, do a full cleanup
// This only happens if cancelled items are stuck in the middle/bottom of the heap // This only happens if cancelled items are stuck in the middle/bottom of the heap
if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) { if (this->to_remove_ >= MAX_LOGICALLY_DELETED_ITEMS) {
// We hold the lock for the entire cleanup operation because: this->full_cleanup_removed_items_();
// 1. We're rebuilding the entire items_ list, so we need exclusive access throughout
// 2. Other threads must see either the old state or the new state, not intermediate states
// 3. The operation is already expensive (O(n)), so lock overhead is negligible
// 4. No operations inside can block or take other locks, so no deadlock risk
LockGuard guard{this->lock_};
std::vector<std::unique_ptr<SchedulerItem>> valid_items;
// Move all non-removed items to valid_items, recycle removed ones
for (auto &item : this->items_) {
if (!is_item_removed_(item.get())) {
valid_items.push_back(std::move(item));
} else {
// Recycle removed items
this->recycle_item_(std::move(item));
}
}
// Replace items_ with the filtered list
this->items_ = std::move(valid_items);
// Rebuild the heap structure since items are no longer in heap order
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
this->to_remove_ = 0;
} }
while (!this->items_.empty()) { while (!this->items_.empty()) {
// Don't copy-by value yet // Don't copy-by value yet

View File

@@ -263,7 +263,65 @@ class Scheduler {
// Helper to recycle a SchedulerItem // Helper to recycle a SchedulerItem
void recycle_item_(std::unique_ptr<SchedulerItem> item); void recycle_item_(std::unique_ptr<SchedulerItem> item);
// Helper to perform full cleanup when too many items are cancelled
void full_cleanup_removed_items_();
#ifndef ESPHOME_THREAD_SINGLE #ifndef ESPHOME_THREAD_SINGLE
// Helper to process defer queue - inline for performance in hot path
inline void process_defer_queue_(uint32_t &now) {
// Process defer queue first to guarantee FIFO execution order for deferred items.
// Previously, defer() used the heap which gave undefined order for equal timestamps,
// causing race conditions on multi-core systems (ESP32, BK7200).
// With the defer queue:
// - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_
// - Items execute in exact order they were deferred (FIFO guarantee)
// - No deferred items exist in to_add_, so processing order doesn't affect correctness
// Single-core platforms don't use this queue and fall back to the heap-based approach.
//
// Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still
// processed here. They are skipped during execution by should_skip_item_().
// This is intentional - no memory leak occurs.
//
// We use an index (defer_queue_front_) to track the read position instead of calling
// erase() on every pop, which would be O(n). The queue is processed once per loop -
// any items added during processing are left for the next loop iteration.
// Snapshot the queue end point - only process items that existed at loop start
// Items added during processing (by callbacks or other threads) run next loop
// No lock needed: single consumer (main loop), stale read just means we process less this iteration
size_t defer_queue_end = this->defer_queue_.size();
while (this->defer_queue_front_ < defer_queue_end) {
std::unique_ptr<SchedulerItem> item;
{
LockGuard lock(this->lock_);
// SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_.
// This is intentional and safe because:
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_
// and has_cancelled_timeout_in_container_ in scheduler.h)
// 3. The lock protects concurrent access, but the nullptr remains until cleanup
item = std::move(this->defer_queue_[this->defer_queue_front_]);
this->defer_queue_front_++;
}
// Execute callback without holding lock to prevent deadlocks
// if the callback tries to call defer() again
if (!this->should_skip_item_(item.get())) {
now = this->execute_item_(item.get(), now);
}
// Recycle the defer item after execution
this->recycle_item_(std::move(item));
}
// If we've consumed all items up to the snapshot point, clean up the dead space
// Single consumer (main loop), so no lock needed for this check
if (this->defer_queue_front_ >= defer_queue_end) {
LockGuard lock(this->lock_);
this->cleanup_defer_queue_locked_();
}
}
// Helper to cleanup defer_queue_ after processing // Helper to cleanup defer_queue_ after processing
// IMPORTANT: Caller must hold the scheduler lock before calling this function. // IMPORTANT: Caller must hold the scheduler lock before calling this function.
inline void cleanup_defer_queue_locked_() { inline void cleanup_defer_queue_locked_() {

View File

@@ -350,7 +350,7 @@ def safe_exp(obj: SafeExpType) -> Expression:
return IntLiteral(int(obj.total_seconds)) return IntLiteral(int(obj.total_seconds))
if isinstance(obj, TimePeriodMinutes): if isinstance(obj, TimePeriodMinutes):
return IntLiteral(int(obj.total_minutes)) return IntLiteral(int(obj.total_minutes))
if isinstance(obj, tuple | list): if isinstance(obj, (tuple, list)):
return ArrayInitializer(*[safe_exp(o) for o in obj]) return ArrayInitializer(*[safe_exp(o) for o in obj])
if obj is bool: if obj is bool:
return bool_ return bool_

View File

@@ -133,7 +133,6 @@ ignore = [
"PLW1641", # Object does not implement `__hash__` method "PLW1641", # Object does not implement `__hash__` method
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"UP038", # https://github.com/astral-sh/ruff/issues/7871 https://github.com/astral-sh/ruff/pull/16681
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View File

@@ -1,6 +1,6 @@
pylint==4.0.2 pylint==4.0.2
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.2 # also change in .pre-commit-config.yaml when updating ruff==0.14.3 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating
pre-commit pre-commit

View File

@@ -87,3 +87,99 @@ api:
- float_arr.size() - float_arr.size()
- string_arr[0].c_str() - string_arr[0].c_str()
- string_arr.size() - string_arr.size()
# Test ContinuationAction (IfAction with then/else branches)
- action: test_if_action
variables:
condition: bool
value: int
then:
- if:
condition:
lambda: 'return condition;'
then:
- logger.log:
format: "Condition true, value: %d"
args: ['value']
else:
- logger.log:
format: "Condition false, value: %d"
args: ['value']
- logger.log: "After if/else"
# Test nested IfAction (multiple ContinuationAction instances)
- action: test_nested_if
variables:
outer: bool
inner: bool
then:
- if:
condition:
lambda: 'return outer;'
then:
- if:
condition:
lambda: 'return inner;'
then:
- logger.log: "Both true"
else:
- logger.log: "Outer true, inner false"
else:
- logger.log: "Outer false"
- logger.log: "After nested if"
# Test WhileLoopContinuation (WhileAction)
- action: test_while_action
variables:
max_count: int
then:
- lambda: 'id(api_continuation_test_counter) = 0;'
- while:
condition:
lambda: 'return id(api_continuation_test_counter) < max_count;'
then:
- logger.log:
format: "While loop iteration: %d"
args: ['id(api_continuation_test_counter)']
- lambda: 'id(api_continuation_test_counter)++;'
- logger.log: "After while loop"
# Test RepeatLoopContinuation (RepeatAction)
- action: test_repeat_action
variables:
count: int
then:
- repeat:
count: !lambda 'return count;'
then:
- logger.log:
format: "Repeat iteration: %d"
args: ['iteration']
- logger.log: "After repeat"
# Test combined continuations (if + while + repeat)
- action: test_combined_continuations
variables:
do_loop: bool
loop_count: int
then:
- if:
condition:
lambda: 'return do_loop;'
then:
- repeat:
count: !lambda 'return loop_count;'
then:
- lambda: 'id(api_continuation_test_counter) = iteration;'
- while:
condition:
lambda: 'return id(api_continuation_test_counter) > 0;'
then:
- logger.log:
format: "Combined: repeat=%d, while=%d"
args: ['iteration', 'id(api_continuation_test_counter)']
- lambda: 'id(api_continuation_test_counter)--;'
else:
- logger.log: "Skipped loops"
- logger.log: "After combined test"
globals:
- id: api_continuation_test_counter
type: int
restore_value: false
initial_value: '0'

View File

@@ -10,7 +10,11 @@ esphome:
on_shutdown: on_shutdown:
logger.log: on_shutdown logger.log: on_shutdown
on_loop: on_loop:
logger.log: on_loop if:
condition:
component.is_idle: binary_sensor_id
then:
logger.log: on_loop - sensor idle
compile_process_limit: 1 compile_process_limit: 1
min_version: "2025.1" min_version: "2025.1"
name_add_mac_suffix: true name_add_mac_suffix: true
@@ -34,5 +38,6 @@ esphome:
binary_sensor: binary_sensor:
- platform: template - platform: template
id: binary_sensor_id
name: Other device sensor name: Other device sensor
device_id: other_device device_id: other_device

View File

@@ -21,12 +21,12 @@ font:
id: roboto_greek id: roboto_greek
size: 20 size: 20
glyphs: ["\u0300", "\u00C5", "\U000000C7"] glyphs: ["\u0300", "\u00C5", "\U000000C7"]
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf" - file: "https://media.esphome.io/tests/fonts/Monocraft.ttf"
id: monocraft id: monocraft
size: 20 size: 20
- file: - file:
type: web type: web
url: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf" url: "https://media.esphome.io/tests/fonts/Monocraft.ttf"
id: monocraft2 id: monocraft2
size: 24 size: 24
- file: $component_dir/Monocraft.ttf - file: $component_dir/Monocraft.ttf

View File

@@ -21,12 +21,12 @@ font:
id: roboto_greek id: roboto_greek
size: 20 size: 20
glyphs: ["\u0300", "\u00C5", "\U000000C7"] glyphs: ["\u0300", "\u00C5", "\U000000C7"]
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf" - file: "https://media.esphome.io/tests/fonts/Monocraft.ttf"
id: monocraft id: monocraft
size: 20 size: 20
- file: - file:
type: web type: web
url: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf" url: "https://media.esphome.io/tests/fonts/Monocraft.ttf"
id: monocraft2 id: monocraft2
size: 24 size: 24
- file: $component_dir/Monocraft.ttf - file: $component_dir/Monocraft.ttf

View File

@@ -50,16 +50,16 @@ image:
transparency: opaque transparency: opaque
- id: web_svg_image - id: web_svg_image
file: https://raw.githubusercontent.com/esphome/esphome-docs/a62d7ab193c1a464ed791670170c7d518189109b/images/logo.svg file: https://media.esphome.io/logo/logo.svg
resize: 256x48 resize: 256x48
type: BINARY type: BINARY
transparency: chroma_key transparency: chroma_key
- id: web_tiff_image - id: web_tiff_image
file: https://upload.wikimedia.org/wikipedia/commons/b/b6/SIPI_Jelly_Beans_4.1.07.tiff file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff
type: RGB type: RGB
resize: 48x48 resize: 48x48
- id: web_redirect_image - id: web_redirect_image
file: https://avatars.githubusercontent.com/u/3060199?s=48&v=4 file: https://media.esphome.io/logo/logo.png
type: RGB type: RGB
resize: 48x48 resize: 48x48
- id: mdi_alert - id: mdi_alert

View File

@@ -14,12 +14,14 @@ interval:
// Test parse_json // Test parse_json
bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) { bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) {
if (root.containsKey("sensor") && root.containsKey("value")) { if (root["sensor"].is<const char*>() && root["value"].is<float>()) {
const char* sensor = root["sensor"]; const char* sensor = root["sensor"];
float value = root["value"]; float value = root["value"];
ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value); ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value);
return true;
} else { } else {
ESP_LOGD("test", "Parsed JSON missing required keys"); ESP_LOGD("test", "Parsed JSON missing required keys");
return false;
} }
}); });
ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed"); ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed");

View File

@@ -68,5 +68,13 @@ lvgl:
enter_button: pushbutton enter_button: pushbutton
group: general group: general
initial_focus: lv_roller initial_focus: lv_roller
on_draw_start:
- logger.log: draw started
on_draw_end:
- logger.log: draw ended
- lvgl.pause:
- component.update: tft_display
- delay: 60s
- lvgl.resume:
<<: !include common.yaml <<: !include common.yaml

View File

@@ -0,0 +1,2 @@
network:
enable_ipv6: true

View File

@@ -0,0 +1,3 @@
nrf52:
# it is not correct bootloader for the board
bootloader: adafruit_nrf52_sd140_v6

View File

@@ -14,10 +14,10 @@ display:
binary_sensor: binary_sensor:
- platform: sdl - platform: sdl
id: key_up id: key_up
key: SDLK_a key: SDLK_UP
- platform: sdl - platform: sdl
id: key_down id: key_down
key: SDLK_d key: SDLK_DOWN
- platform: sdl - platform: sdl
id: key_enter id: key_enter
key: SDLK_s key: SDLK_RETURN

View File

@@ -0,0 +1,29 @@
esphome:
name: test-web-server-idf
esp32:
board: esp32dev
framework:
type: esp-idf
network:
# Add some entities to test SSE event formatting
sensor:
- platform: template
name: "Test Sensor"
id: test_sensor
update_interval: 60s
lambda: "return 42.5;"
binary_sensor:
- platform: template
name: "Test Binary Sensor"
id: test_binary_sensor
lambda: "return true;"
switch:
- platform: template
name: "Test Switch"
id: test_switch
optimistic: true

View File

@@ -0,0 +1,3 @@
<<: !include common.yaml
web_server:

View File

@@ -0,0 +1,105 @@
esphome:
name: action-concurrent-reentry
on_boot:
- priority: -100
then:
- repeat:
count: 5
then:
- lambda: id(handler_wait_until)->execute(id(global_counter));
- lambda: id(handler_repeat)->execute(id(global_counter));
- lambda: id(handler_while)->execute(id(global_counter));
- lambda: id(handler_script_wait)->execute(id(global_counter));
- delay: 50ms
- lambda: id(global_counter)++;
- delay: 50ms
host:
api:
globals:
- id: global_counter
type: int
script:
- id: handler_wait_until
mode: parallel
parameters:
arg: int
then:
- wait_until:
condition:
lambda: return id(global_counter) == 5;
- logger.log:
format: "AFTER wait_until ARG %d"
args:
- arg
- id: handler_script_wait
mode: parallel
parameters:
arg: int
then:
- script.wait: handler_wait_until
- logger.log:
format: "AFTER script.wait ARG %d"
args:
- arg
- id: handler_repeat
mode: parallel
parameters:
arg: int
then:
- repeat:
count: 3
then:
- logger.log:
format: "IN repeat %d ARG %d"
args:
- iteration
- arg
- delay: 100ms
- logger.log:
format: "AFTER repeat ARG %d"
args:
- arg
- id: handler_while
mode: parallel
parameters:
arg: int
then:
- while:
condition:
lambda: return id(global_counter) != 5;
then:
- logger.log:
format: "IN while ARG %d"
args:
- arg
- delay: 100ms
- logger.log:
format: "AFTER while ARG %d"
args:
- arg
logger:
level: DEBUG

View File

@@ -0,0 +1,130 @@
esphome:
name: test-automation-wait-actions
host:
api:
actions:
# Test 1: Trigger wait_until automation 5 times rapidly
- action: test_wait_until
then:
- logger.log: "=== TEST 1: Triggering wait_until automation 5 times ==="
# Publish 5 different values to trigger the on_value automation 5 times
- sensor.template.publish:
id: wait_until_sensor
state: 1
- sensor.template.publish:
id: wait_until_sensor
state: 2
- sensor.template.publish:
id: wait_until_sensor
state: 3
- sensor.template.publish:
id: wait_until_sensor
state: 4
- sensor.template.publish:
id: wait_until_sensor
state: 5
# Wait then satisfy the condition so all 5 waiting actions complete
- delay: 100ms
- globals.set:
id: test_flag
value: 'true'
# Test 2: Trigger script.wait automation 5 times rapidly
- action: test_script_wait
then:
- logger.log: "=== TEST 2: Triggering script.wait automation 5 times ==="
# Start a long-running script
- script.execute: blocking_script
# Publish 5 different values to trigger the on_value automation 5 times
- sensor.template.publish:
id: script_wait_sensor
state: 1
- sensor.template.publish:
id: script_wait_sensor
state: 2
- sensor.template.publish:
id: script_wait_sensor
state: 3
- sensor.template.publish:
id: script_wait_sensor
state: 4
- sensor.template.publish:
id: script_wait_sensor
state: 5
# Test 3: Trigger wait_until timeout automation 5 times rapidly
- action: test_wait_timeout
then:
- logger.log: "=== TEST 3: Triggering timeout automation 5 times ==="
# Publish 5 different values (condition will never be true, all will timeout)
- sensor.template.publish:
id: timeout_sensor
state: 1
- sensor.template.publish:
id: timeout_sensor
state: 2
- sensor.template.publish:
id: timeout_sensor
state: 3
- sensor.template.publish:
id: timeout_sensor
state: 4
- sensor.template.publish:
id: timeout_sensor
state: 5
logger:
level: DEBUG
globals:
- id: test_flag
type: bool
restore_value: false
initial_value: 'false'
- id: timeout_flag
type: bool
restore_value: false
initial_value: 'false'
# Sensors with wait_until/script.wait in their on_value automations
sensor:
# Test 1: on_value automation with wait_until
- platform: template
id: wait_until_sensor
on_value:
# This wait_until will be hit 5 times before any complete
- wait_until:
condition:
lambda: return id(test_flag);
- logger.log: "wait_until automation completed"
# Test 2: on_value automation with script.wait
- platform: template
id: script_wait_sensor
on_value:
# This script.wait will be hit 5 times before any complete
- script.wait: blocking_script
- logger.log: "script.wait automation completed"
# Test 3: on_value automation with wait_until timeout
- platform: template
id: timeout_sensor
on_value:
# This wait_until will be hit 5 times, all will timeout
- wait_until:
condition:
lambda: return id(timeout_flag);
timeout: 200ms
- logger.log: "timeout automation completed"
script:
# Blocking script for script.wait test
- id: blocking_script
mode: single
then:
- logger.log: "Blocking script: START"
- delay: 200ms
- logger.log: "Blocking script: END"

View File

@@ -0,0 +1,174 @@
esphome:
name: test-continuation-actions
host:
api:
actions:
# Test 1: IfAction with ContinuationAction (then/else branches)
- action: test_if_action
variables:
condition: bool
value: int
then:
- logger.log:
format: "Test if: condition=%s, value=%d"
args: ['YESNO(condition)', 'value']
- if:
condition:
lambda: 'return condition;'
then:
- logger.log:
format: "if-then executed: value=%d"
args: ['value']
else:
- logger.log:
format: "if-else executed: value=%d"
args: ['value']
- logger.log: "if completed"
# Test 2: Nested IfAction (multiple ContinuationAction instances)
- action: test_nested_if
variables:
outer: bool
inner: bool
then:
- logger.log:
format: "Test nested if: outer=%s, inner=%s"
args: ['YESNO(outer)', 'YESNO(inner)']
- if:
condition:
lambda: 'return outer;'
then:
- if:
condition:
lambda: 'return inner;'
then:
- logger.log: "nested-both-true"
else:
- logger.log: "nested-outer-true-inner-false"
else:
- logger.log: "nested-outer-false"
- logger.log: "nested if completed"
# Test 3: WhileAction with WhileLoopContinuation
- action: test_while_action
variables:
max_count: int
then:
- logger.log:
format: "Test while: max_count=%d"
args: ['max_count']
- globals.set:
id: continuation_test_counter
value: !lambda 'return 0;'
- while:
condition:
lambda: 'return id(continuation_test_counter) < max_count;'
then:
- logger.log:
format: "while-iteration-%d"
args: ['id(continuation_test_counter)']
- globals.set:
id: continuation_test_counter
value: !lambda 'return id(continuation_test_counter) + 1;'
- logger.log: "while completed"
# Test 4: RepeatAction with RepeatLoopContinuation
- action: test_repeat_action
variables:
count: int
then:
- logger.log:
format: "Test repeat: count=%d"
args: ['count']
- repeat:
count: !lambda 'return count;'
then:
- logger.log:
format: "repeat-iteration-%d"
args: ['iteration']
- logger.log: "repeat completed"
# Test 5: Combined continuations (if + while + repeat)
- action: test_combined
variables:
do_loop: bool
loop_count: int
then:
- logger.log:
format: "Test combined: do_loop=%s, loop_count=%d"
args: ['YESNO(do_loop)', 'loop_count']
- if:
condition:
lambda: 'return do_loop;'
then:
- repeat:
count: !lambda 'return loop_count;'
then:
- globals.set:
id: continuation_test_counter
value: !lambda 'return iteration;'
- while:
condition:
lambda: 'return id(continuation_test_counter) > 0;'
then:
- logger.log:
format: "combined-repeat%d-while%d"
args: ['iteration', 'id(continuation_test_counter)']
- globals.set:
id: continuation_test_counter
value: !lambda 'return id(continuation_test_counter) - 1;'
else:
- logger.log: "combined-skipped"
- logger.log: "combined completed"
# Test 6: Rapid triggers to verify memory efficiency
- action: test_rapid_if
then:
- logger.log: "=== Rapid if test start ==="
- sensor.template.publish:
id: rapid_sensor
state: 1
- sensor.template.publish:
id: rapid_sensor
state: 2
- sensor.template.publish:
id: rapid_sensor
state: 3
- sensor.template.publish:
id: rapid_sensor
state: 4
- sensor.template.publish:
id: rapid_sensor
state: 5
- logger.log: "=== Rapid if test published 5 values ==="
logger:
level: DEBUG
globals:
- id: continuation_test_counter
type: int
restore_value: false
initial_value: '0'
# Sensor to test rapid automation triggers with if/else (ContinuationAction)
sensor:
- platform: template
id: rapid_sensor
on_value:
- if:
condition:
lambda: 'return x > 2;'
then:
- logger.log:
format: "rapid-if-then: value=%d"
args: ['(int)x']
else:
- logger.log:
format: "rapid-if-else: value=%d"
args: ['(int)x']
- logger.log:
format: "rapid-if-completed: value=%d"
args: ['(int)x']

View File

@@ -0,0 +1,92 @@
"""Integration test for API conditional memory optimization with triggers and services."""
from __future__ import annotations
import asyncio
import collections
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_action_concurrent_reentry(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""
This test runs a script in parallel with varying arguments and verifies if
each script keeps its original argument throughout its execution
"""
test_complete = asyncio.Event()
expected = {0, 1, 2, 3, 4}
# Patterns to match in logs
after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)")
after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)")
after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)")
in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)")
after_while_pattern = re.compile(r"AFTER while ARG (\d+)")
in_while_pattern = re.compile(r"IN while ARG (\d+)")
after_wait_until_args = []
after_script_wait_args = []
after_while_args = []
in_while_args = []
after_repeat_args = []
in_repeat_args = collections.defaultdict(list)
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if test_complete.is_set():
return
if mo := after_wait_until_pattern.search(line):
after_wait_until_args.append(int(mo.group(1)))
elif mo := after_script_wait_pattern.search(line):
after_script_wait_args.append(int(mo.group(1)))
elif mo := in_while_pattern.search(line):
in_while_args.append(int(mo.group(1)))
elif mo := after_while_pattern.search(line):
after_while_args.append(int(mo.group(1)))
elif mo := in_repeat_pattern.search(line):
in_repeat_args[int(mo.group(1))].append(int(mo.group(2)))
elif mo := after_repeat_pattern.search(line):
after_repeat_args.append(int(mo.group(1)))
if len(after_repeat_args) == len(expected):
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 == "action-concurrent-reentry"
# Wait for tests to complete with timeout
try:
await asyncio.wait_for(test_complete.wait(), timeout=8.0)
except TimeoutError:
pytest.fail("test timed out")
# order may change, but all args must be present
for args in in_repeat_args.values():
assert set(args) == expected
assert set(in_repeat_args.keys()) == {0, 1, 2}
assert set(after_wait_until_args) == expected, after_wait_until_args
assert set(after_script_wait_args) == expected, after_script_wait_args
assert set(after_repeat_args) == expected, after_repeat_args
assert set(after_while_args) == expected, after_while_args
assert dict(collections.Counter(in_while_args)) == {
0: 5,
1: 4,
2: 3,
3: 2,
4: 1,
}, in_while_args

View File

@@ -0,0 +1,104 @@
"""Test concurrent execution of wait_until and script.wait in direct automation actions."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_automation_wait_actions(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""
Test that wait_until and script.wait correctly handle concurrent executions
when automation actions (not scripts) are triggered multiple times rapidly.
This tests sensor.on_value automations being triggered 5 times before any complete.
"""
loop = asyncio.get_running_loop()
# Track completion counts
test_results = {
"wait_until": 0,
"script_wait": 0,
"wait_until_timeout": 0,
}
# Patterns for log messages
wait_until_complete = re.compile(r"wait_until automation completed")
script_wait_complete = re.compile(r"script\.wait automation completed")
timeout_complete = re.compile(r"timeout automation completed")
# Test completion futures
test1_complete = loop.create_future()
test2_complete = loop.create_future()
test3_complete = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for completion messages."""
# Test 1: wait_until concurrent execution
if wait_until_complete.search(line):
test_results["wait_until"] += 1
if test_results["wait_until"] == 5 and not test1_complete.done():
test1_complete.set_result(True)
# Test 2: script.wait concurrent execution
if script_wait_complete.search(line):
test_results["script_wait"] += 1
if test_results["script_wait"] == 5 and not test2_complete.done():
test2_complete.set_result(True)
# Test 3: wait_until with timeout
if timeout_complete.search(line):
test_results["wait_until_timeout"] += 1
if test_results["wait_until_timeout"] == 5 and not test3_complete.done():
test3_complete.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get services
_, services = await client.list_entities_services()
# Test 1: wait_until in automation - trigger 5 times rapidly
test_service = next((s for s in services if s.name == "test_wait_until"), None)
assert test_service is not None, "test_wait_until service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test1_complete, timeout=3.0)
# Verify Test 1: All 5 triggers should complete
assert test_results["wait_until"] == 5, (
f"Test 1: Expected 5 wait_until completions, got {test_results['wait_until']}"
)
# Test 2: script.wait in automation - trigger 5 times rapidly
test_service = next((s for s in services if s.name == "test_script_wait"), None)
assert test_service is not None, "test_script_wait service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test2_complete, timeout=3.0)
# Verify Test 2: All 5 triggers should complete
assert test_results["script_wait"] == 5, (
f"Test 2: Expected 5 script.wait completions, got {test_results['script_wait']}"
)
# Test 3: wait_until with timeout in automation - trigger 5 times rapidly
test_service = next(
(s for s in services if s.name == "test_wait_timeout"), None
)
assert test_service is not None, "test_wait_timeout service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test3_complete, timeout=3.0)
# Verify Test 3: All 5 triggers should timeout and complete
assert test_results["wait_until_timeout"] == 5, (
f"Test 3: Expected 5 timeout completions, got {test_results['wait_until_timeout']}"
)

View File

@@ -0,0 +1,235 @@
"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation)."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_continuation_actions(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""
Test that continuation actions work correctly for if/while/repeat.
These continuation classes replace LambdaAction with simple parent pointers,
saving 32-36 bytes per instance and eliminating std::function overhead.
"""
loop = asyncio.get_running_loop()
# Track test completions
test_results = {
"if_then": False,
"if_else": False,
"if_complete": False,
"nested_both_true": False,
"nested_outer_true_inner_false": False,
"nested_outer_false": False,
"nested_complete": False,
"while_iterations": 0,
"while_complete": False,
"repeat_iterations": 0,
"repeat_complete": False,
"combined_iterations": 0,
"combined_complete": False,
"rapid_then": 0,
"rapid_else": 0,
"rapid_complete": 0,
}
# Patterns for log messages
if_then_pattern = re.compile(r"if-then executed: value=(\d+)")
if_else_pattern = re.compile(r"if-else executed: value=(\d+)")
if_complete_pattern = re.compile(r"if completed")
nested_both_true_pattern = re.compile(r"nested-both-true")
nested_outer_true_inner_false_pattern = re.compile(r"nested-outer-true-inner-false")
nested_outer_false_pattern = re.compile(r"nested-outer-false")
nested_complete_pattern = re.compile(r"nested if completed")
while_iteration_pattern = re.compile(r"while-iteration-(\d+)")
while_complete_pattern = re.compile(r"while completed")
repeat_iteration_pattern = re.compile(r"repeat-iteration-(\d+)")
repeat_complete_pattern = re.compile(r"repeat completed")
combined_pattern = re.compile(r"combined-repeat(\d+)-while(\d+)")
combined_complete_pattern = re.compile(r"combined completed")
rapid_then_pattern = re.compile(r"rapid-if-then: value=(\d+)")
rapid_else_pattern = re.compile(r"rapid-if-else: value=(\d+)")
rapid_complete_pattern = re.compile(r"rapid-if-completed: value=(\d+)")
# Test completion futures
test1_complete = loop.create_future() # if action
test2_complete = loop.create_future() # nested if
test3_complete = loop.create_future() # while
test4_complete = loop.create_future() # repeat
test5_complete = loop.create_future() # combined
test6_complete = loop.create_future() # rapid
def check_output(line: str) -> None:
"""Check log output for test messages."""
# Test 1: IfAction
if if_then_pattern.search(line):
test_results["if_then"] = True
if if_else_pattern.search(line):
test_results["if_else"] = True
if if_complete_pattern.search(line):
test_results["if_complete"] = True
if not test1_complete.done():
test1_complete.set_result(True)
# Test 2: Nested IfAction
if nested_both_true_pattern.search(line):
test_results["nested_both_true"] = True
if nested_outer_true_inner_false_pattern.search(line):
test_results["nested_outer_true_inner_false"] = True
if nested_outer_false_pattern.search(line):
test_results["nested_outer_false"] = True
if nested_complete_pattern.search(line):
test_results["nested_complete"] = True
if not test2_complete.done():
test2_complete.set_result(True)
# Test 3: WhileAction
if match := while_iteration_pattern.search(line):
test_results["while_iterations"] = max(
test_results["while_iterations"], int(match.group(1)) + 1
)
if while_complete_pattern.search(line):
test_results["while_complete"] = True
if not test3_complete.done():
test3_complete.set_result(True)
# Test 4: RepeatAction
if match := repeat_iteration_pattern.search(line):
test_results["repeat_iterations"] = max(
test_results["repeat_iterations"], int(match.group(1)) + 1
)
if repeat_complete_pattern.search(line):
test_results["repeat_complete"] = True
if not test4_complete.done():
test4_complete.set_result(True)
# Test 5: Combined
if combined_pattern.search(line):
test_results["combined_iterations"] += 1
if combined_complete_pattern.search(line):
test_results["combined_complete"] = True
if not test5_complete.done():
test5_complete.set_result(True)
# Test 6: Rapid triggers
if rapid_then_pattern.search(line):
test_results["rapid_then"] += 1
if rapid_else_pattern.search(line):
test_results["rapid_else"] += 1
if rapid_complete_pattern.search(line):
test_results["rapid_complete"] += 1
if test_results["rapid_complete"] == 5 and not test6_complete.done():
test6_complete.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get services
_, services = await client.list_entities_services()
# Test 1: IfAction with then branch
test_service = next((s for s in services if s.name == "test_if_action"), None)
assert test_service is not None, "test_if_action service not found"
client.execute_service(test_service, {"condition": True, "value": 42})
await asyncio.wait_for(test1_complete, timeout=2.0)
assert test_results["if_then"], "IfAction then branch not executed"
assert test_results["if_complete"], "IfAction did not complete"
# Test 1b: IfAction with else branch
test1_complete = loop.create_future()
test_results["if_complete"] = False
client.execute_service(test_service, {"condition": False, "value": 99})
await asyncio.wait_for(test1_complete, timeout=2.0)
assert test_results["if_else"], "IfAction else branch not executed"
assert test_results["if_complete"], "IfAction did not complete"
# Test 2: Nested IfAction - test all branches
test_service = next((s for s in services if s.name == "test_nested_if"), None)
assert test_service is not None, "test_nested_if service not found"
# Both true
client.execute_service(test_service, {"outer": True, "inner": True})
await asyncio.wait_for(test2_complete, timeout=2.0)
assert test_results["nested_both_true"], "Nested both true not executed"
# Outer true, inner false
test2_complete = loop.create_future()
test_results["nested_complete"] = False
client.execute_service(test_service, {"outer": True, "inner": False})
await asyncio.wait_for(test2_complete, timeout=2.0)
assert test_results["nested_outer_true_inner_false"], (
"Nested outer true inner false not executed"
)
# Outer false
test2_complete = loop.create_future()
test_results["nested_complete"] = False
client.execute_service(test_service, {"outer": False, "inner": True})
await asyncio.wait_for(test2_complete, timeout=2.0)
assert test_results["nested_outer_false"], "Nested outer false not executed"
# Test 3: WhileAction
test_service = next(
(s for s in services if s.name == "test_while_action"), None
)
assert test_service is not None, "test_while_action service not found"
client.execute_service(test_service, {"max_count": 3})
await asyncio.wait_for(test3_complete, timeout=2.0)
assert test_results["while_iterations"] == 3, (
f"WhileAction expected 3 iterations, got {test_results['while_iterations']}"
)
assert test_results["while_complete"], "WhileAction did not complete"
# Test 4: RepeatAction
test_service = next(
(s for s in services if s.name == "test_repeat_action"), None
)
assert test_service is not None, "test_repeat_action service not found"
client.execute_service(test_service, {"count": 5})
await asyncio.wait_for(test4_complete, timeout=2.0)
assert test_results["repeat_iterations"] == 5, (
f"RepeatAction expected 5 iterations, got {test_results['repeat_iterations']}"
)
assert test_results["repeat_complete"], "RepeatAction did not complete"
# Test 5: Combined (if + repeat + while)
test_service = next((s for s in services if s.name == "test_combined"), None)
assert test_service is not None, "test_combined service not found"
client.execute_service(test_service, {"do_loop": True, "loop_count": 2})
await asyncio.wait_for(test5_complete, timeout=2.0)
# Should execute: repeat 2 times, each iteration does while from iteration down to 0
# iteration 0: while 0 times = 0
# iteration 1: while 1 time = 1
# Total: 1 combined log
assert test_results["combined_iterations"] >= 1, (
f"Combined expected >=1 iterations, got {test_results['combined_iterations']}"
)
assert test_results["combined_complete"], "Combined did not complete"
# Test 6: Rapid triggers (tests memory efficiency of ContinuationAction)
test_service = next((s for s in services if s.name == "test_rapid_if"), None)
assert test_service is not None, "test_rapid_if service not found"
client.execute_service(test_service, {})
await asyncio.wait_for(test6_complete, timeout=2.0)
# Values 1, 2 should hit else (<=2), values 3, 4, 5 should hit then (>2)
assert test_results["rapid_else"] == 2, (
f"Rapid test expected 2 else, got {test_results['rapid_else']}"
)
assert test_results["rapid_then"] == 3, (
f"Rapid test expected 3 then, got {test_results['rapid_then']}"
)
assert test_results["rapid_complete"] == 5, (
f"Rapid test expected 5 completions, got {test_results['rapid_complete']}"
)

View File

@@ -33,3 +33,4 @@ test_list:
{{{ "x", "79"}, { "y", "82"}}} {{{ "x", "79"}, { "y", "82"}}}
- '{{{"AA"}}}' - '{{{"AA"}}}'
- '"HELLO"' - '"HELLO"'
- '{ 79, 82 }'

View File

@@ -34,3 +34,4 @@ test_list:
{{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}} {{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}}
- ${ '{{{"AA"}}}' } - ${ '{{{"AA"}}}' }
- ${ '"HELLO"' } - ${ '"HELLO"' }
- '{ ${position.x}, ${position.y} }'

View File

@@ -3,6 +3,7 @@ import string
from hypothesis import example, given from hypothesis import example, given
from hypothesis.strategies import builds, integers, ip_addresses, one_of, text from hypothesis.strategies import builds, integers, ip_addresses, one_of, text
import pytest import pytest
import voluptuous as vol
from esphome import config_validation from esphome import config_validation
from esphome.components.esp32.const import ( from esphome.components.esp32.const import (
@@ -301,8 +302,6 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple)
], ],
) )
def test_require_framework_version(framework, platform, message): def test_require_framework_version(framework, platform, message):
import voluptuous as vol
from esphome.const import ( from esphome.const import (
KEY_CORE, KEY_CORE,
KEY_FRAMEWORK_VERSION, KEY_FRAMEWORK_VERSION,
@@ -377,3 +376,129 @@ def test_require_framework_version(framework, platform, message):
config_validation.require_framework_version( config_validation.require_framework_version(
extra_message="test 5", extra_message="test 5",
)("test") )("test")
def test_only_with_single_component_loaded() -> None:
"""Test OnlyWith with single component when component is loaded."""
CORE.loaded_integrations = {"mqtt"}
schema = config_validation.Schema(
{
config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str,
}
)
result = schema({})
assert result.get("mqtt_id") == "test_mqtt"
def test_only_with_single_component_not_loaded() -> None:
"""Test OnlyWith with single component when component is not loaded."""
CORE.loaded_integrations = set()
schema = config_validation.Schema(
{
config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str,
}
)
result = schema({})
assert "mqtt_id" not in result
def test_only_with_list_all_components_loaded() -> None:
"""Test OnlyWith with list when all components are loaded."""
CORE.loaded_integrations = {"zigbee", "nrf52"}
schema = config_validation.Schema(
{
config_validation.OnlyWith(
"zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"
): str,
}
)
result = schema({})
assert result.get("zigbee_id") == "test_zigbee"
def test_only_with_list_partial_components_loaded() -> None:
"""Test OnlyWith with list when only some components are loaded."""
CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52
schema = config_validation.Schema(
{
config_validation.OnlyWith(
"zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"
): str,
}
)
result = schema({})
assert "zigbee_id" not in result
def test_only_with_list_no_components_loaded() -> None:
"""Test OnlyWith with list when no components are loaded."""
CORE.loaded_integrations = set()
schema = config_validation.Schema(
{
config_validation.OnlyWith(
"zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"
): str,
}
)
result = schema({})
assert "zigbee_id" not in result
def test_only_with_list_multiple_components() -> None:
"""Test OnlyWith with list requiring three components."""
CORE.loaded_integrations = {"comp1", "comp2", "comp3"}
schema = config_validation.Schema(
{
config_validation.OnlyWith(
"test_id", ["comp1", "comp2", "comp3"], default="test_value"
): str,
}
)
result = schema({})
assert result.get("test_id") == "test_value"
# Test with one missing
CORE.loaded_integrations = {"comp1", "comp2"}
result = schema({})
assert "test_id" not in result
def test_only_with_empty_list() -> None:
"""Test OnlyWith with empty list (edge case)."""
CORE.loaded_integrations = set()
schema = config_validation.Schema(
{
config_validation.OnlyWith("test_id", [], default="test_value"): str,
}
)
# all([]) returns True, so default should be applied
result = schema({})
assert result.get("test_id") == "test_value"
def test_only_with_user_value_overrides_default() -> None:
"""Test OnlyWith respects user-provided values over defaults."""
CORE.loaded_integrations = {"mqtt"}
schema = config_validation.Schema(
{
config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str,
}
)
result = schema({"mqtt_id": "custom_id"})
assert result.get("mqtt_id") == "custom_id"