1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-15 06:15:47 +00:00

Compare commits

..

43 Commits

Author SHA1 Message Date
J. Nick Koston
f1bc3c68dd [core] Optimize DelayAction for no-argument case using if constexpr 2025-11-14 20:09:38 -06:00
J. Nick Koston
f8191410e3 [core] Optimize DelayAction for no-argument case using if constexpr 2025-11-14 20:05:43 -06:00
dependabot[bot]
1df996601d Bump ruff from 0.14.4 to 0.14.5 (#11910)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-11-14 19:14:07 +00:00
dependabot[bot]
c32891ec02 Bump github/codeql-action from 4.31.2 to 4.31.3 (#11911)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-14 13:09:59 -06:00
Jonathan Swoboda
2bf6d48fcf [uart] Improve error handling and validate buffer size (#11895)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-11-14 14:06:08 -05:00
Edward Firmo
e49a943cf7 [wifi] Allow use_psram with Arduino (#11902) 2025-11-14 09:13:48 -05:00
dependabot[bot]
67524e14ee Bump pylint from 4.0.2 to 4.0.3 (#11894)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-13 19:05:02 +00:00
Edward Firmo
2290eb0dd2 [light] Fix missing ColorMode::BRIGHTNESS case in logging (#11836) 2025-11-13 12:08:06 -06:00
Clyde Stubbs
0afcf67c32 [esp32] Add sdkconfig flag to make OTA work for 32MB flash (#11883)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2025-11-13 10:52:08 -05:00
Clyde Stubbs
952bdfaac2 [esp32] Make esp-idf default framework for P4 (#11884) 2025-11-13 09:55:48 -05:00
Jesse Hills
ed7e5cd325 Bump version to 2025.12.0-dev 2025-11-13 17:00:47 +13:00
Jonathan Swoboda
a15f46e741 Merge branch 'beta' into dev 2025-11-12 22:46:34 -05:00
Jonathan Swoboda
050a27a409 Merge pull request #11880 from esphome/bump-2025.11.0b2
2025.11.0b2
2025-11-12 22:46:23 -05:00
Jonathan Swoboda
382483b063 Bump version to 2025.11.0b2 2025-11-12 21:56:11 -05:00
J. Nick Koston
1675408161 [wifi] Fix slow reconnection after connection loss for all network types (#11873)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 21:56:11 -05:00
J. Nick Koston
1d8b08dcce [wifi][ethernet] Fix spurious warnings and unclear status after PR #9823 (#11871) 2025-11-12 21:56:11 -05:00
J. Nick Koston
afed581079 [light] Fix dangling reference in compute_color_mode causing memory corruption (#11868) 2025-11-12 21:56:11 -05:00
J. Nick Koston
ff107a0674 [mqtt] Fix crash with empty broker during upload/logs (#11866)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 21:56:11 -05:00
J. Nick Koston
72da3d0f1e [thermostat] Replace std::map with FixedVector, reduce flash usage (#11875) 2025-11-12 21:56:11 -05:00
J. Nick Koston
5a2e6697e0 [api][event] Send events immediately to prevent loss during rapid triggers (#11777) 2025-11-12 21:56:11 -05:00
J. Nick Koston
799cfe1de4 [esp32_ble_tracker] Use initializer_list to eliminate compiler warning and reduce flash usage (#11861) 2025-11-12 21:56:11 -05:00
J. Nick Koston
6df0264d51 [api] Eliminate heap allocations when transmitting Event types (#11773) 2025-11-12 21:56:11 -05:00
J. Nick Koston
a859ecaad1 [core] Fix wait_until hanging when used in on_boot automations (#11869) 2025-11-12 21:56:11 -05:00
Jonathan Swoboda
4f088c93c9 [esp32] Update the recommended platform to 55.03.31-2 (#11865) 2025-11-12 21:56:11 -05:00
J. Nick Koston
a1ab19d127 [ci] Reduce release time by removing 21 redundant ESP32-S3 IDF tests (#11850) 2025-11-12 21:56:11 -05:00
tomaszduda23
d869108416 [nrf52] add settings for dcdc converter (#11841) 2025-11-12 20:06:20 -06:00
J. Nick Koston
2d6618da3c [wifi] Fix slow reconnection after connection loss for all network types (#11873)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 13:44:22 +13:00
J. Nick Koston
47fe84e922 [wifi][ethernet] Fix spurious warnings and unclear status after PR #9823 (#11871) 2025-11-13 13:43:51 +13:00
J. Nick Koston
735bf9930a [light] Fix dangling reference in compute_color_mode causing memory corruption (#11868) 2025-11-13 13:41:28 +13:00
J. Nick Koston
769137fc09 [mqtt] Fix crash with empty broker during upload/logs (#11866)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 13:40:26 +13:00
J. Nick Koston
3a5b3ad77d [thermostat] Replace std::map with FixedVector, reduce flash usage (#11875) 2025-11-12 17:55:06 -06:00
J. Nick Koston
859101ddc9 [api][event] Send events immediately to prevent loss during rapid triggers (#11777) 2025-11-13 12:42:50 +13:00
J. Nick Koston
29a50da635 [wifi] Use stack allocation for BSSID formatting in logging (#11859) 2025-11-12 14:27:06 -06:00
J. Nick Koston
5f0fa68d73 [esp32_ble] Use stack allocation for MAC formatting in dump_config (#11860) 2025-11-12 14:26:57 -06:00
J. Nick Koston
2f39b10baa [esp32_ble_tracker] Use initializer_list to eliminate compiler warning and reduce flash usage (#11861) 2025-11-12 14:26:46 -06:00
J. Nick Koston
5a550cc579 [api] Eliminate heap allocations when transmitting Event types (#11773) 2025-11-12 14:26:36 -06:00
J. Nick Koston
4b58cb4ce6 [wifi] Pass ManualIP by const reference to reduce stack usage (#11858) 2025-11-12 14:01:19 -06:00
J. Nick Koston
3872a2fd91 [captive_portal] Warn when enabled without WiFi AP configured (#11856) 2025-11-12 14:01:07 -06:00
dependabot[bot]
5d613ada83 Bump pytest from 9.0.0 to 9.0.1 (#11874)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-12 14:00:50 -06:00
J. Nick Koston
9de80b635a [core] Fix wait_until hanging when used in on_boot automations (#11869) 2025-11-12 17:56:19 +00:00
Jonathan Swoboda
748aee584a [esp32] Update the recommended platform to 55.03.31-2 (#11865) 2025-11-12 10:41:22 -05:00
Jonathan Swoboda
3cbfddcc83 Merge branch 'beta' into dev 2025-11-11 23:27:24 -05:00
J. Nick Koston
398dba4fc8 [ci] Reduce release time by removing 21 redundant ESP32-S3 IDF tests (#11850) 2025-11-12 16:44:19 +13:00
59 changed files with 896 additions and 495 deletions

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
category: "/language:${{matrix.language}}"

View File

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

View File

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

View File

@@ -476,8 +476,9 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c
auto *light = static_cast<light::LightState *>(entity);
ListEntitiesLightResponse msg;
auto traits = light->get_traits();
auto supported_modes = traits.get_supported_color_modes();
// Pass pointer to ColorModeMask so the iterator can encode actual ColorMode enum values
msg.supported_color_modes = &traits.get_supported_color_modes();
msg.supported_color_modes = &supported_modes;
if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) ||
traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) {
msg.min_mireds = traits.get_min_mireds();
@@ -1294,11 +1295,11 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe
#endif
#ifdef USE_EVENT
void APIConnection::send_event(event::Event *event, const std::string &event_type) {
this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE,
EventResponse::ESTIMATED_SIZE);
void APIConnection::send_event(event::Event *event, const char *event_type) {
this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE,
EventResponse::ESTIMATED_SIZE);
}
uint16_t APIConnection::try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn,
uint16_t APIConnection::try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn,
uint32_t remaining_size, bool is_single) {
EventResponse resp;
resp.set_event_type(StringRef(event_type));
@@ -1650,9 +1651,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c
// O(n) but optimized for RAM and not performance.
for (auto &item : items) {
if (item.entity == entity && item.message_type == message_type) {
// Clean up old creator before replacing
item.creator.cleanup(message_type);
// Move assign the new creator
// Replace with new creator
item.creator = std::move(creator);
return;
}
@@ -1822,7 +1821,7 @@ void APIConnection::process_batch_() {
// Handle remaining items more efficiently
if (items_processed < this->deferred_batch_.size()) {
// Remove processed items from the beginning with proper cleanup
// Remove processed items from the beginning
this->deferred_batch_.remove_front(items_processed);
// Reschedule for remaining items
this->schedule_batch_();
@@ -1835,10 +1834,10 @@ void APIConnection::process_batch_() {
uint16_t APIConnection::MessageCreator::operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size,
bool is_single, uint8_t message_type) const {
#ifdef USE_EVENT
// Special case: EventResponse uses string pointer
// Special case: EventResponse uses const char * pointer
if (message_type == EventResponse::MESSAGE_TYPE) {
auto *e = static_cast<event::Event *>(entity);
return APIConnection::try_send_event_response(e, *data_.string_ptr, conn, remaining_size, is_single);
return APIConnection::try_send_event_response(e, data_.const_char_ptr, conn, remaining_size, is_single);
}
#endif

View File

@@ -177,7 +177,7 @@ class APIConnection final : public APIServerConnection {
#endif
#ifdef USE_EVENT
void send_event(event::Event *event, const std::string &event_type);
void send_event(event::Event *event, const char *event_type);
#endif
#ifdef USE_UPDATE
@@ -450,7 +450,7 @@ class APIConnection final : public APIServerConnection {
bool is_single);
#endif
#ifdef USE_EVENT
static uint16_t try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn,
static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn,
uint32_t remaining_size, bool is_single);
static uint16_t try_send_event_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single);
#endif
@@ -508,10 +508,8 @@ class APIConnection final : public APIServerConnection {
// Constructor for function pointer
MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; }
// Constructor for string state capture
explicit MessageCreator(const std::string &str_value) { data_.string_ptr = new std::string(str_value); }
// No destructor - cleanup must be called explicitly with message_type
// Constructor for const char * (Event types - no allocation needed)
explicit MessageCreator(const char *str_value) { data_.const_char_ptr = str_value; }
// Delete copy operations - MessageCreator should only be moved
MessageCreator(const MessageCreator &other) = delete;
@@ -523,8 +521,6 @@ class APIConnection final : public APIServerConnection {
// Move assignment
MessageCreator &operator=(MessageCreator &&other) noexcept {
if (this != &other) {
// IMPORTANT: Caller must ensure cleanup() was called if this contains a string!
// In our usage, this happens in add_item() deduplication and vector::erase()
data_ = other.data_;
other.data_.function_ptr = nullptr;
}
@@ -535,20 +531,10 @@ class APIConnection final : public APIServerConnection {
uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single,
uint8_t message_type) const;
// Manual cleanup method - must be called before destruction for string types
void cleanup(uint8_t message_type) {
#ifdef USE_EVENT
if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) {
delete data_.string_ptr;
data_.string_ptr = nullptr;
}
#endif
}
private:
union Data {
MessageCreatorPtr function_ptr;
std::string *string_ptr;
const char *const_char_ptr;
} data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before
};
@@ -568,42 +554,24 @@ class APIConnection final : public APIServerConnection {
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
private:
// Helper to cleanup items from the beginning
void cleanup_items_(size_t count) {
for (size_t i = 0; i < count; i++) {
items[i].creator.cleanup(items[i].message_type);
}
}
public:
DeferredBatch() {
// Pre-allocate capacity for typical batch sizes to avoid reallocation
items.reserve(8);
}
~DeferredBatch() {
// Ensure cleanup of any remaining items
clear();
}
// Add item to the batch
void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size);
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size);
// Clear all items with proper cleanup
// Clear all items
void clear() {
cleanup_items_(items.size());
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front with proper cleanup
void remove_front(size_t count) {
cleanup_items_(count);
items.erase(items.begin(), items.begin() + count);
}
// Remove processed items from the front
void remove_front(size_t count) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
@@ -682,21 +650,30 @@ class APIConnection final : public APIServerConnection {
}
#endif
// Helper to check if a message type should bypass batching
// Returns true if:
// 1. It's an UpdateStateResponse (always send immediately to handle cases where
// the main loop is blocked, e.g., during OTA updates)
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
// AND batch_delay = 0)
inline bool should_send_immediately_(uint8_t message_type) const {
return (
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
#endif
#ifdef USE_EVENT
message_type == EventResponse::MESSAGE_TYPE ||
#endif
(this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0));
}
// Helper method to send a message either immediately or via batching
// Tries immediate send if should_send_immediately_() returns true and buffer has space
// Falls back to batching if immediate send fails or isn't applicable
bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type,
uint8_t estimated_size) {
// Try to send immediately if:
// 1. It's an UpdateStateResponse (always send immediately to handle cases where
// the main loop is blocked, e.g., during OTA updates)
// 2. OR: We should try to send immediately (should_try_send_immediately = true)
// AND Batch delay is 0 (user has opted in to immediate sending)
// 3. AND: Buffer has space available
if ((
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
#endif
(this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) &&
this->helper_->can_write_without_blocking()) {
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
// Now actually encode and send
if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) &&
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
@@ -714,6 +691,27 @@ class APIConnection final : public APIServerConnection {
return this->schedule_message_(entity, creator, message_type, estimated_size);
}
// Overload for MessageCreator (used by events which need to capture event_type)
bool send_message_smart_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) {
// Try to send immediately if message type should bypass batching and buffer has space
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
// Now actually encode and send
if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type) &&
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
#ifdef HAS_PROTO_MESSAGE_DUMP
// Log the message in verbose mode
this->log_proto_message_(entity, creator, message_type);
#endif
return true;
}
// If immediate send failed, fall through to batching
}
// Fall back to scheduled batching
return this->schedule_message_(entity, std::move(creator), message_type, estimated_size);
}
// Helper function to schedule a deferred message with known message type
bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) {
this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size);

View File

@@ -1,9 +1,12 @@
import logging
import esphome.codegen as cg
from esphome.components import web_server_base
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_ID,
PLATFORM_BK72XX,
PLATFORM_ESP32,
@@ -14,6 +17,10 @@ from esphome.const import (
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def AUTO_LOAD() -> list[str]:
@@ -50,6 +57,27 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
wifi_conf = full_config.get("wifi")
if wifi_conf is None:
# This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway
raise cv.Invalid("Captive portal requires the wifi component to be configured")
if CONF_AP not in wifi_conf:
_LOGGER.warning(
"Captive portal is enabled but no WiFi AP is configured. "
"The captive portal will not be accessible. "
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL)
async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])

View File

@@ -334,12 +334,14 @@ def _is_framework_url(source: str) -> str:
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 3, 2),
"latest": cv.Version(3, 3, 2),
"dev": cv.Version(3, 3, 2),
"latest": cv.Version(3, 3, 4),
"dev": cv.Version(3, 3, 4),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 2): cv.Version(55, 3, 31, "1"),
cv.Version(3, 3, 1): cv.Version(55, 3, 31, "1"),
cv.Version(3, 3, 4): cv.Version(55, 3, 31, "2"),
cv.Version(3, 3, 3): cv.Version(55, 3, 31, "2"),
cv.Version(3, 3, 2): cv.Version(55, 3, 31, "2"),
cv.Version(3, 3, 1): cv.Version(55, 3, 31, "2"),
cv.Version(3, 3, 0): cv.Version(55, 3, 30, "2"),
cv.Version(3, 2, 1): cv.Version(54, 3, 21, "2"),
cv.Version(3, 2, 0): cv.Version(54, 3, 20),
@@ -357,8 +359,8 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"dev": cv.Version(5, 5, 1),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(5, 5, 1): cv.Version(55, 3, 31, "1"),
cv.Version(5, 5, 0): cv.Version(55, 3, 31, "1"),
cv.Version(5, 5, 1): cv.Version(55, 3, 31, "2"),
cv.Version(5, 5, 0): cv.Version(55, 3, 31, "2"),
cv.Version(5, 4, 3): cv.Version(55, 3, 32),
cv.Version(5, 4, 2): cv.Version(54, 3, 21, "2"),
cv.Version(5, 4, 1): cv.Version(54, 3, 21, "2"),
@@ -373,14 +375,15 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# The platform-espressif32 version
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
"recommended": cv.Version(55, 3, 31, "1"),
"latest": cv.Version(55, 3, 31, "1"),
"dev": cv.Version(55, 3, 31, "1"),
"recommended": cv.Version(55, 3, 31, "2"),
"latest": cv.Version(55, 3, 31, "2"),
"dev": cv.Version(55, 3, 31, "2"),
}
def _check_versions(value):
value = value.copy()
def _check_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
@@ -445,7 +448,7 @@ def _check_versions(value):
"If there are connectivity or build issues please remove the manual version."
)
return value
return config
def _parse_platform_version(value):
@@ -495,6 +498,8 @@ def final_validate(config):
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
errs = []
conf_fw = config[CONF_FRAMEWORK]
advanced = conf_fw[CONF_ADVANCED]
full_config = fv.full_config.get()
if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS):
pio_flash_size_key = "board_upload.flash_size"
@@ -511,22 +516,14 @@ def final_validate(config):
f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only"
)
)
if (
config[CONF_VARIANT] != VARIANT_ESP32
and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK])
and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED]
):
if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_IGNORE_EFUSE_MAC_CRC]:
errs.append(
cv.Invalid(
f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC],
)
)
if (
config.get(CONF_FRAMEWORK, {})
.get(CONF_ADVANCED, {})
.get(CONF_EXECUTE_FROM_PSRAM)
):
if advanced[CONF_EXECUTE_FROM_PSRAM]:
if config[CONF_VARIANT] != VARIANT_ESP32S3:
errs.append(
cv.Invalid(
@@ -542,6 +539,17 @@ def final_validate(config):
)
)
if (
config[CONF_FLASH_SIZE] == "32MB"
and "ota" in full_config
and not advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES]
):
errs.append(
cv.Invalid(
f"OTA with 32MB flash requires '{CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES}' to be set in the '{CONF_ADVANCED}' section of the esp32 configuration",
path=[CONF_FLASH_SIZE],
)
)
if errs:
raise cv.MultipleInvalid(errs)
@@ -596,89 +604,74 @@ def _validate_idf_component(config: ConfigType) -> ConfigType:
FRAMEWORK_ESP_IDF = "esp-idf"
FRAMEWORK_ARDUINO = "arduino"
FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_TYPE, default=FRAMEWORK_ARDUINO): cv.one_of(
FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO
),
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_RELEASE): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict
},
cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of(
*LOG_LEVELS_IDF, upper=True
),
cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
{
cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of(
*ASSERTION_LEVELS, upper=True
),
cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of(
*COMPILER_OPTIMIZATIONS, upper=True
),
cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean,
cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean,
cv.Optional(
CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False
): cv.boolean,
cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean,
# DHCP server is needed for WiFi AP mode. When WiFi component is used,
# it will handle disabling DHCP server when AP is not configured.
# Default to false (disabled) when WiFi is not used.
cv.OnlyWithout(
CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_MDNS_QUERIES, default=True
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True
): cv.boolean,
cv.Optional(
CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True
): cv.boolean,
cv.Optional(
CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True
): cv.boolean,
cv.Optional(
CONF_DISABLE_VFS_SUPPORT_SELECT, default=True
): cv.boolean,
cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean,
cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean,
cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range(
min=8192, max=32768
),
}
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
cv.Optional(CONF_REFRESH): cv.All(
cv.string, cv.source_refresh
),
}
),
_validate_idf_component,
)
),
}
),
_check_versions,
FRAMEWORK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO),
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_RELEASE): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict
},
cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of(
*LOG_LEVELS_IDF, upper=True
),
cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
{
cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of(
*ASSERTION_LEVELS, upper=True
),
cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of(
*COMPILER_OPTIMIZATIONS, upper=True
),
cv.Optional(
CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, default=False
): cv.boolean,
cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean,
cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean,
cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean,
# DHCP server is needed for WiFi AP mode. When WiFi component is used,
# it will handle disabling DHCP server when AP is not configured.
# Default to false (disabled) when WiFi is not used.
cv.OnlyWithout(
CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False
): cv.boolean,
cv.Optional(CONF_ENABLE_LWIP_MDNS_QUERIES, default=True): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True
): cv.boolean,
cv.Optional(
CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True
): cv.boolean,
cv.Optional(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean,
cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean,
cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range(
min=8192, max=32768
),
}
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh),
}
),
_validate_idf_component,
)
),
}
)
@@ -741,11 +734,11 @@ def _show_framework_migration_message(name: str, variant: str) -> None:
def _set_default_framework(config):
config = config.copy()
if CONF_FRAMEWORK not in config:
config = config.copy()
variant = config[CONF_VARIANT]
config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({})
if CONF_TYPE not in config[CONF_FRAMEWORK]:
variant = config[CONF_VARIANT]
if variant in ARDUINO_ALLOWED_VARIANTS:
config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO
_show_framework_migration_message(
@@ -785,6 +778,7 @@ CONFIG_SCHEMA = cv.All(
),
_detect_variant,
_set_default_framework,
_check_versions,
set_core_data,
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
)
@@ -803,9 +797,7 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
from esphome.components.socket import KEY_SOCKET_CONSUMERS
# Check if user manually specified CONFIG_LWIP_MAX_SOCKETS
user_max_sockets = conf.get(CONF_SDKCONFIG_OPTIONS, {}).get(
"CONFIG_LWIP_MAX_SOCKETS"
)
user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS")
socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {})
total_sockets = sum(socket_consumers.values())
@@ -975,23 +967,18 @@ async def to_code(config):
# WiFi component handles its own optimization when AP mode is not used
# When using Arduino with Ethernet, DHCP server functions must be available
# for the Network library to compile, even if not actively used
if (
CONF_ENABLE_LWIP_DHCP_SERVER in advanced
and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER]
and not (
conf[CONF_TYPE] == FRAMEWORK_ARDUINO
and "ethernet" in CORE.loaded_integrations
)
if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not (
conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations
):
add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False)
if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False):
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
_configure_lwip_max_sockets(conf)
if advanced.get(CONF_EXECUTE_FROM_PSRAM, False):
if advanced[CONF_EXECUTE_FROM_PSRAM]:
add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True)
add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True)
@@ -1002,23 +989,22 @@ async def to_code(config):
# - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default)
# - Up to 200% slower under load when all operations queue through tcpip_thread
# Enabling this makes ESP-IDF socket performance match Arduino framework.
if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True):
if advanced[CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING]:
add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True)
if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True):
if advanced[CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY]:
add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True)
# Disable placing libc locks in IRAM to save RAM
# This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled)
# use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM.
if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True):
if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
# Disable VFS support for termios (terminal I/O functions)
# ESPHome doesn't use termios functions on ESP32 (only used in host UART driver).
# Saves approximately 1.8KB of flash when disabled (default).
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True),
"CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS]
)
# Disable VFS support for select() with file descriptors
@@ -1032,8 +1018,7 @@ async def to_code(config):
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_SELECT",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True),
"CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT]
)
# Disable VFS support for directory functions (opendir, readdir, mkdir, etc.)
@@ -1046,8 +1031,7 @@ async def to_code(config):
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_DIR",
not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True),
"CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR]
)
cg.add_platformio_option("board_build.partitions", "partitions.csv")
@@ -1061,7 +1045,7 @@ async def to_code(config):
add_idf_sdkconfig_option(flag, assertion_level == key)
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -1070,18 +1054,20 @@ async def to_code(config):
conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT],
)
if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC):
if advanced[CONF_IGNORE_EFUSE_MAC_CRC]:
add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True)
add_idf_sdkconfig_option("CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False)
if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES):
if advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES]:
_LOGGER.warning(
"Using experimental features in ESP-IDF may result in unexpected failures."
)
add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True)
if config[CONF_FLASH_SIZE] == "32MB":
add_idf_sdkconfig_option(
"CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True
)
cg.add_define(
"ESPHOME_LOOP_TASK_STACK_SIZE", advanced.get(CONF_LOOP_TASK_STACK_SIZE)
)
cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE])
cg.add_define(
"USE_ESP_IDF_VERSION_CODE",

View File

@@ -634,11 +634,13 @@ void ESP32BLE::dump_config() {
io_capability_s = "invalid";
break;
}
char mac_s[18];
format_mac_addr_upper(mac_address, mac_s);
ESP_LOGCONFIG(TAG,
"BLE:\n"
" MAC address: %s\n"
" IO Capability: %s",
format_mac_address_pretty(mac_address).c_str(), io_capability_s);
mac_s, io_capability_s);
} else {
ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled");
}

View File

@@ -10,7 +10,7 @@ namespace esphome::esp32_ble_tracker {
class ESPBTAdvertiseTrigger : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); }
void set_addresses(const std::vector<uint64_t> &addresses) { this->address_vec_ = addresses; }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->address_vec_ = addresses; }
bool parse_device(const ESPBTDevice &device) override {
uint64_t u64_addr = device.address_uint64();

View File

@@ -381,7 +381,10 @@ void EthernetComponent::dump_config() {
break;
}
ESP_LOGCONFIG(TAG, "Ethernet:");
ESP_LOGCONFIG(TAG,
"Ethernet:\n"
" Connected: %s",
YESNO(this->is_connected()));
this->dump_connect_params_();
#ifdef USE_ETHERNET_SPI
ESP_LOGCONFIG(TAG,

View File

@@ -52,8 +52,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) {
}
static const LogString *color_mode_to_human(ColorMode color_mode) {
if (color_mode == ColorMode::UNKNOWN)
return LOG_STR("Unknown");
if (color_mode == ColorMode::ON_OFF)
return LOG_STR("On/Off");
if (color_mode == ColorMode::BRIGHTNESS)
return LOG_STR("Brightness");
if (color_mode == ColorMode::WHITE)
return LOG_STR("White");
if (color_mode == ColorMode::COLOR_TEMPERATURE)
@@ -68,7 +70,7 @@ static const LogString *color_mode_to_human(ColorMode color_mode) {
return LOG_STR("RGB + cold/warm white");
if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE)
return LOG_STR("RGB + color temperature");
return LOG_STR("");
return LOG_STR("Unknown");
}
// Helper to log percentage values
@@ -406,7 +408,7 @@ void LightCall::transform_parameters_() {
}
}
ColorMode LightCall::compute_color_mode_() {
const auto &supported_modes = this->parent_->get_traits().get_supported_color_modes();
auto supported_modes = this->parent_->get_traits().get_supported_color_modes();
int supported_count = supported_modes.size();
// Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown.

View File

@@ -18,7 +18,8 @@ class LightTraits {
public:
LightTraits() = default;
const ColorModeMask &get_supported_color_modes() const { return this->supported_color_modes_; }
// Return by value to avoid dangling reference when get_traits() returns a temporary
ColorModeMask get_supported_color_modes() const { return this->supported_color_modes_; }
void set_supported_color_modes(ColorModeMask supported_color_modes) {
this->supported_color_modes_ = supported_color_modes;
}

View File

@@ -118,10 +118,10 @@ struct IPAddress {
operator arduino_ns::IPAddress() const { return ip_addr_get_ip4_u32(&ip_addr_); }
#endif
bool is_set() { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr)
bool is_ip4() { return IP_IS_V4(&ip_addr_); }
bool is_ip6() { return IP_IS_V6(&ip_addr_); }
bool is_multicast() { return ip_addr_ismulticast(&ip_addr_); }
bool is_set() const { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr)
bool is_ip4() const { return IP_IS_V4(&ip_addr_); }
bool is_ip6() const { return IP_IS_V6(&ip_addr_); }
bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); }
std::string str() const { return str_lower_case(ipaddr_ntoa(&ip_addr_)); }
bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); }
bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); }

View File

@@ -103,6 +103,7 @@ nrf52_ns = cg.esphome_ns.namespace("nrf52")
DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component)
CONF_DFU = "dfu"
CONF_DCDC = "dcdc"
CONF_REG0 = "reg0"
CONF_UICR_ERASE = "uicr_erase"
@@ -121,6 +122,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
),
cv.Optional(CONF_DCDC, default=True): cv.boolean,
cv.Optional(CONF_REG0): cv.Schema(
{
cv.Required(CONF_VOLTAGE): cv.All(
@@ -196,6 +198,7 @@ async def to_code(config: ConfigType) -> None:
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC])
if reg0_config := config.get(CONF_REG0):
value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE])

View File

@@ -945,6 +945,10 @@ async def to_code(config):
cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS]))
if CONF_PRESET in config:
# Separate standard and custom presets, and build preset config variables
standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = []
custom_presets: list[tuple[str, cg.MockObj]] = []
for preset_config in config[CONF_PRESET]:
name = preset_config[CONF_NAME]
standard_preset = None
@@ -987,9 +991,39 @@ async def to_code(config):
)
if standard_preset is not None:
cg.add(var.set_preset_config(standard_preset, preset_target_variable))
standard_presets.append((standard_preset, preset_target_variable))
else:
cg.add(var.set_custom_preset_config(name, preset_target_variable))
custom_presets.append((name, preset_target_variable))
# Build initializer list for standard presets
if standard_presets:
cg.add(
var.set_preset_config(
[
cg.StructInitializer(
thermostat_ns.struct("ThermostatPresetEntry"),
("preset", preset),
("config", preset_var),
)
for preset, preset_var in standard_presets
]
)
)
# Build initializer list for custom presets
if custom_presets:
cg.add(
var.set_custom_preset_config(
[
cg.StructInitializer(
thermostat_ns.struct("ThermostatCustomPresetEntry"),
("name", cg.RawExpression(f'"{name}"')),
("config", preset_var),
)
for name, preset_var in custom_presets
]
)
)
if CONF_DEFAULT_PRESET in config:
default_preset_name = config[CONF_DEFAULT_PRESET]

View File

@@ -53,8 +53,8 @@ void ThermostatClimate::setup() {
if (use_default_preset) {
if (this->default_preset_ != climate::ClimatePreset::CLIMATE_PRESET_NONE) {
this->change_preset_(this->default_preset_);
} else if (!this->default_custom_preset_.empty()) {
this->change_custom_preset_(this->default_custom_preset_.c_str());
} else if (this->default_custom_preset_ != nullptr) {
this->change_custom_preset_(this->default_custom_preset_);
}
}
@@ -319,16 +319,16 @@ climate::ClimateTraits ThermostatClimate::traits() {
if (this->supports_swing_mode_vertical_)
traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
for (auto &it : this->preset_config_) {
traits.add_supported_preset(it.first);
for (const auto &entry : this->preset_config_) {
traits.add_supported_preset(entry.preset);
}
// Extract custom preset names from the custom_preset_config_ map
// Extract custom preset names from the custom_preset_config_ vector
if (!this->custom_preset_config_.empty()) {
std::vector<const char *> custom_preset_names;
custom_preset_names.reserve(this->custom_preset_config_.size());
for (const auto &it : this->custom_preset_config_) {
custom_preset_names.push_back(it.first.c_str());
for (const auto &entry : this->custom_preset_config_) {
custom_preset_names.push_back(entry.name);
}
traits.set_supported_custom_presets(custom_preset_names);
}
@@ -1154,12 +1154,18 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm
}
void ThermostatClimate::change_preset_(climate::ClimatePreset preset) {
auto config = this->preset_config_.find(preset);
// Linear search through preset configurations
const ThermostatClimateTargetTempConfig *config = nullptr;
for (const auto &entry : this->preset_config_) {
if (entry.preset == preset) {
config = &entry.config;
break;
}
}
if (config != this->preset_config_.end()) {
if (config != nullptr) {
ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset)));
if (this->change_preset_internal_(config->second) || (!this->preset.has_value()) ||
this->preset.value() != preset) {
if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) {
// Fire any preset changed trigger if defined
Trigger<> *trig = this->preset_change_trigger_;
this->set_preset_(preset);
@@ -1178,11 +1184,18 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) {
}
void ThermostatClimate::change_custom_preset_(const char *custom_preset) {
auto config = this->custom_preset_config_.find(custom_preset);
// Linear search through custom preset configurations
const ThermostatClimateTargetTempConfig *config = nullptr;
for (const auto &entry : this->custom_preset_config_) {
if (strcmp(entry.name, custom_preset) == 0) {
config = &entry.config;
break;
}
}
if (config != this->custom_preset_config_.end()) {
if (config != nullptr) {
ESP_LOGV(TAG, "Custom preset %s requested", custom_preset);
if (this->change_preset_internal_(config->second) || !this->has_custom_preset() ||
if (this->change_preset_internal_(*config) || !this->has_custom_preset() ||
strcmp(this->get_custom_preset(), custom_preset) != 0) {
// Fire any preset changed trigger if defined
Trigger<> *trig = this->preset_change_trigger_;
@@ -1247,14 +1260,12 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem
return something_changed;
}
void ThermostatClimate::set_preset_config(climate::ClimatePreset preset,
const ThermostatClimateTargetTempConfig &config) {
this->preset_config_[preset] = config;
void ThermostatClimate::set_preset_config(std::initializer_list<PresetEntry> presets) {
this->preset_config_ = presets;
}
void ThermostatClimate::set_custom_preset_config(const std::string &name,
const ThermostatClimateTargetTempConfig &config) {
this->custom_preset_config_[name] = config;
void ThermostatClimate::set_custom_preset_config(std::initializer_list<CustomPresetEntry> presets) {
this->custom_preset_config_ = presets;
}
ThermostatClimate::ThermostatClimate()
@@ -1293,8 +1304,16 @@ ThermostatClimate::ThermostatClimate()
humidity_control_humidify_action_trigger_(new Trigger<>()),
humidity_control_off_action_trigger_(new Trigger<>()) {}
void ThermostatClimate::set_default_preset(const std::string &custom_preset) {
this->default_custom_preset_ = custom_preset;
void ThermostatClimate::set_default_preset(const char *custom_preset) {
// Find the preset in custom_preset_config_ and store pointer from there
for (const auto &entry : this->custom_preset_config_) {
if (strcmp(entry.name, custom_preset) == 0) {
this->default_custom_preset_ = entry.name;
return;
}
}
// If not found, it will be caught during validation
this->default_custom_preset_ = nullptr;
}
void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; }
@@ -1605,19 +1624,22 @@ void ThermostatClimate::dump_config() {
if (!this->preset_config_.empty()) {
ESP_LOGCONFIG(TAG, " Supported PRESETS:");
for (auto &it : this->preset_config_) {
const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first));
ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_preset_ ? " (default)" : "");
this->dump_preset_config_(preset_name, it.second);
for (const auto &entry : this->preset_config_) {
const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset));
ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? " (default)" : "");
this->dump_preset_config_(preset_name, entry.config);
}
}
if (!this->custom_preset_config_.empty()) {
ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:");
for (auto &it : this->custom_preset_config_) {
const auto *preset_name = it.first.c_str();
ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_custom_preset_ ? " (default)" : "");
this->dump_preset_config_(preset_name, it.second);
for (const auto &entry : this->custom_preset_config_) {
const auto *preset_name = entry.name;
ESP_LOGCONFIG(TAG, " %s:%s", preset_name,
(this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0)
? " (default)"
: "");
this->dump_preset_config_(preset_name, entry.config);
}
}
}

View File

@@ -3,12 +3,12 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/climate/climate.h"
#include "esphome/components/sensor/sensor.h"
#include <array>
#include <cinttypes>
#include <map>
namespace esphome {
namespace thermostat {
@@ -72,14 +72,29 @@ struct ThermostatClimateTargetTempConfig {
optional<climate::ClimateMode> mode_{};
};
/// Entry for standard preset lookup
struct ThermostatPresetEntry {
climate::ClimatePreset preset;
ThermostatClimateTargetTempConfig config;
};
/// Entry for custom preset lookup
struct ThermostatCustomPresetEntry {
const char *name;
ThermostatClimateTargetTempConfig config;
};
class ThermostatClimate : public climate::Climate, public Component {
public:
using PresetEntry = ThermostatPresetEntry;
using CustomPresetEntry = ThermostatCustomPresetEntry;
ThermostatClimate();
void setup() override;
void dump_config() override;
void loop() override;
void set_default_preset(const std::string &custom_preset);
void set_default_preset(const char *custom_preset);
void set_default_preset(climate::ClimatePreset preset);
void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from);
void set_set_point_minimum_differential(float differential);
@@ -131,8 +146,8 @@ class ThermostatClimate : public climate::Climate, public Component {
void set_supports_humidification(bool supports_humidification);
void set_supports_two_points(bool supports_two_points);
void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config);
void set_custom_preset_config(const std::string &name, const ThermostatClimateTargetTempConfig &config);
void set_preset_config(std::initializer_list<PresetEntry> presets);
void set_custom_preset_config(std::initializer_list<CustomPresetEntry> presets);
Trigger<> *get_cool_action_trigger() const;
Trigger<> *get_supplemental_cool_action_trigger() const;
@@ -516,9 +531,6 @@ class ThermostatClimate : public climate::Climate, public Component {
Trigger<> *prev_swing_mode_trigger_{nullptr};
Trigger<> *prev_humidity_control_trigger_{nullptr};
/// Default custom preset to use on start up
std::string default_custom_preset_{};
/// Climate action timers
std::array<ThermostatClimateTimer, THERMOSTAT_TIMER_COUNT> timer_{
ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)),
@@ -534,9 +546,12 @@ class ThermostatClimate : public climate::Climate, public Component {
};
/// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc)
std::map<climate::ClimatePreset, ThermostatClimateTargetTempConfig> preset_config_{};
FixedVector<PresetEntry> preset_config_{};
/// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset")
std::map<std::string, ThermostatClimateTargetTempConfig> custom_preset_config_{};
FixedVector<CustomPresetEntry> custom_preset_config_{};
/// Default custom preset to use on start up (pointer to entry in custom_preset_config_)
private:
const char *default_custom_preset_{nullptr};
};
} // namespace thermostat

View File

@@ -1,3 +1,4 @@
from logging import getLogger
import math
import re
@@ -35,6 +36,8 @@ from esphome.core import CORE, ID
import esphome.final_validate as fv
from esphome.yaml_util import make_data_base
_LOGGER = getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
uart_ns = cg.esphome_ns.namespace("uart")
UARTComponent = uart_ns.class_("UARTComponent")
@@ -130,6 +133,21 @@ def validate_host_config(config):
return config
def validate_rx_buffer_size(config):
if CORE.is_esp32:
# ESP32 UART hardware FIFO is 128 bytes (LP UART is 16 bytes, but we use 128 as safe minimum)
# rx_buffer_size must be greater than the hardware FIFO length
min_buffer_size = 128
if config[CONF_RX_BUFFER_SIZE] <= min_buffer_size:
_LOGGER.warning(
"UART rx_buffer_size (%d bytes) is too small and must be greater than the hardware "
"FIFO size (%d bytes). The buffer size will be automatically adjusted at runtime.",
config[CONF_RX_BUFFER_SIZE],
min_buffer_size,
)
return config
def _uart_declare_type(value):
if CORE.is_esp8266:
return cv.declare_id(ESP8266UartComponent)(value)
@@ -247,6 +265,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT),
validate_host_config,
validate_rx_buffer_size,
)

View File

@@ -91,6 +91,16 @@ void IDFUARTComponent::setup() {
this->uart_num_ = static_cast<uart_port_t>(next_uart_num++);
this->lock_ = xSemaphoreCreateMutex();
#if (SOC_UART_LP_NUM >= 1)
size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN);
#else
size_t fifo_len = SOC_UART_FIFO_LEN;
#endif
if (this->rx_buffer_size_ <= fifo_len) {
ESP_LOGW(TAG, "rx_buffer_size is too small, must be greater than %zu", fifo_len);
this->rx_buffer_size_ = fifo_len * 2;
}
xSemaphoreTake(this->lock_, portMAX_DELAY);
this->load_settings(false);
@@ -237,8 +247,12 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) {
void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
xSemaphoreTake(this->lock_, portMAX_DELAY);
uart_write_bytes(this->uart_num_, data, len);
int32_t write_len = uart_write_bytes(this->uart_num_, data, len);
xSemaphoreGive(this->lock_);
if (write_len != (int32_t) len) {
ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len);
this->mark_failed();
}
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(UART_DIRECTION_TX, data[i]);
@@ -267,6 +281,7 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) {
bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
size_t length_to_read = len;
int32_t read_len = 0;
if (!this->check_read_timeout_(len))
return false;
xSemaphoreTake(this->lock_, portMAX_DELAY);
@@ -277,25 +292,31 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
this->has_peek_ = false;
}
if (length_to_read > 0)
uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS);
read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS);
xSemaphoreGive(this->lock_);
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(UART_DIRECTION_RX, data[i]);
}
#endif
return true;
return read_len == (int32_t) length_to_read;
}
int IDFUARTComponent::available() {
size_t available;
size_t available = 0;
esp_err_t err;
xSemaphoreTake(this->lock_, portMAX_DELAY);
uart_get_buffered_data_len(this->uart_num_, &available);
if (this->has_peek_)
available++;
err = uart_get_buffered_data_len(this->uart_num_, &available);
xSemaphoreGive(this->lock_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err));
this->mark_failed();
}
if (this->has_peek_) {
available++;
}
return available;
}

View File

@@ -12,7 +12,6 @@ from esphome.components.network import (
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.config_validation import only_with_esp_idf
from esphome.const import (
CONF_AP,
CONF_BSSID,
@@ -352,7 +351,7 @@ CONFIG_SCHEMA = cv.All(
single=True
),
cv.Optional(CONF_USE_PSRAM): cv.All(
only_with_esp_idf, cv.requires_component("psram"), cv.boolean
cv.only_on_esp32, cv.requires_component("psram"), cv.boolean
),
}
),

View File

@@ -465,6 +465,8 @@ void WiFiComponent::loop() {
if (!this->is_connected()) {
ESP_LOGW(TAG, "Connection lost; reconnecting");
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING;
// Clear error flag before reconnecting so first attempt is not seen as immediate failure
this->error_from_callback_ = false;
this->retry_connect();
} else {
this->status_clear_warning();
@@ -668,25 +670,25 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa
void WiFiComponent::start_connecting(const WiFiAP &ap) {
// Log connection attempt at INFO level with priority
std::string bssid_formatted;
char bssid_s[18];
int8_t priority = 0;
if (ap.get_bssid().has_value()) {
bssid_formatted = format_mac_address_pretty(ap.get_bssid().value().data());
format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s);
priority = this->get_sta_priority(ap.get_bssid().value());
}
ESP_LOGI(TAG,
"Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_formatted.c_str() : LOG_STR_LITERAL("any"),
priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_),
ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority,
this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_),
LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
#ifdef ESPHOME_LOG_HAS_VERBOSE
ESP_LOGV(TAG, "Connection Params:");
ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str());
if (ap.get_bssid().has_value()) {
ESP_LOGV(TAG, " BSSID: %s", format_mac_address_pretty(ap.get_bssid()->data()).c_str());
ESP_LOGV(TAG, " BSSID: %s", bssid_s);
} else {
ESP_LOGV(TAG, " BSSID: Not Set");
}
@@ -743,6 +745,14 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) {
}
const LogString *get_signal_bars(int8_t rssi) {
// Check for disconnected sentinel value first
if (rssi == WIFI_RSSI_DISCONNECTED) {
// MULTIPLICATION SIGN
// Unicode: U+00D7, UTF-8: C3 97
return LOG_STR("\033[0;31m" // red
"\xc3\x97\xc3\x97\xc3\x97\xc3\x97"
"\033[0m");
}
// LOWER ONE QUARTER BLOCK
// Unicode: U+2582, UTF-8: E2 96 82
// LOWER HALF BLOCK
@@ -787,6 +797,8 @@ const LogString *get_signal_bars(int8_t rssi) {
void WiFiComponent::print_connect_params_() {
bssid_t bssid = wifi_bssid();
char bssid_s[18];
format_mac_addr_upper(bssid.data(), bssid_s);
ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str());
if (this->is_disabled()) {
@@ -809,9 +821,9 @@ void WiFiComponent::print_connect_params_() {
" Gateway: %s\n"
" DNS1: %s\n"
" DNS2: %s",
wifi_ssid().c_str(), format_mac_address_pretty(bssid.data()).c_str(), App.get_name().c_str(), rssi,
LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str().c_str(),
wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str());
wifi_ssid().c_str(), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(),
wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str());
#ifdef ESPHOME_LOG_HAS_VERBOSE
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) {
ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid()));
@@ -1022,7 +1034,10 @@ void WiFiComponent::check_scanning_finished() {
}
void WiFiComponent::dump_config() {
ESP_LOGCONFIG(TAG, "WiFi:");
ESP_LOGCONFIG(TAG,
"WiFi:\n"
" Connected: %s",
YESNO(this->is_connected()));
this->print_connect_params_();
}
@@ -1047,6 +1062,10 @@ void WiFiComponent::check_connecting_finished() {
// Reset to initial phase on successful connection (don't log transition, just reset state)
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
// Ensure next connection attempt does not inherit error state
// so when WiFi disconnects later we start fresh and don't see
// the first connection as a failure.
this->error_from_callback_ = false;
this->print_connect_params_();
@@ -1133,6 +1152,11 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
}
#endif
// Check if we should try explicit hidden networks before scanning
// This handles reconnection after connection loss where first network is hidden
if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
return WiFiRetryPhase::EXPLICIT_HIDDEN;
}
// No more APs to try, fall back to scan
return WiFiRetryPhase::SCAN_CONNECTING;
@@ -1390,8 +1414,10 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() {
(old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
this->set_sta_priority(failed_bssid.value(), new_priority);
}
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(),
format_mac_address_pretty(failed_bssid.value().data()).c_str(), old_priority, new_priority);
char bssid_s[18];
format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(), bssid_s,
old_priority, new_priority);
// After adjusting priority, check if all priorities are now at minimum
// If so, clear the vector to save memory and reset for fresh start

View File

@@ -52,6 +52,9 @@ extern "C" {
namespace esphome {
namespace wifi {
/// Sentinel value for RSSI when WiFi is not connected
static constexpr int8_t WIFI_RSSI_DISCONNECTED = -127;
struct SavedWifiSettings {
char ssid[33];
char password[65];
@@ -426,7 +429,7 @@ class WiFiComponent : public Component {
bool wifi_sta_pre_setup_();
bool wifi_apply_output_power_(float output_power);
bool wifi_apply_power_save_();
bool wifi_sta_ip_config_(optional<ManualIP> manual_ip);
bool wifi_sta_ip_config_(const optional<ManualIP> &manual_ip);
bool wifi_apply_hostname_();
bool wifi_sta_connect_(const WiFiAP &ap);
void wifi_pre_setup_();
@@ -434,7 +437,7 @@ class WiFiComponent : public Component {
bool wifi_scan_start_(bool passive);
#ifdef USE_WIFI_AP
bool wifi_ap_ip_config_(optional<ManualIP> manual_ip);
bool wifi_ap_ip_config_(const optional<ManualIP> &manual_ip);
bool wifi_start_ap_(const WiFiAP &ap);
#endif // USE_WIFI_AP

View File

@@ -117,7 +117,7 @@ void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t
};
#endif
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -525,8 +525,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf);
s_sta_connect_not_found = true;
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), LOG_STR_ARG(get_disconnect_reason_str(it.reason)));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
LOG_STR_ARG(get_disconnect_reason_str(it.reason)));
s_sta_connect_error = true;
}
s_sta_connected = false;
@@ -730,7 +732,7 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
return false;
@@ -870,7 +872,7 @@ bssid_t WiFiComponent::wifi_bssid() {
return bssid;
}
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); }
network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; }
network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; }

View File

@@ -487,7 +487,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
return true;
}
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -746,8 +746,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGI(TAG, "Disconnected ssid='%s' reason='Station Roaming'", buf);
return;
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
get_disconnect_reason_str(it.reason));
s_sta_connect_error = true;
}
s_sta_connected = false;
@@ -884,7 +886,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esp_err_t err;
// enable AP
@@ -1029,7 +1031,8 @@ bssid_t WiFiComponent::wifi_bssid() {
wifi_ap_record_t info;
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
return bssid;
}
std::copy(info.bssid, info.bssid + 6, bssid.begin());
@@ -1039,7 +1042,8 @@ std::string WiFiComponent::wifi_ssid() {
wifi_ap_record_t info{};
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
return "";
}
auto *ssid_s = reinterpret_cast<const char *>(info.ssid);
@@ -1050,8 +1054,9 @@ int8_t WiFiComponent::wifi_rssi() {
wifi_ap_record_t info;
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
return 0;
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
return WIFI_RSSI_DISCONNECTED;
}
return info.rssi;
}

View File

@@ -68,7 +68,7 @@ bool WiFiComponent::wifi_sta_pre_setup_() {
return true;
}
bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); }
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -299,8 +299,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
if (it.reason == WIFI_REASON_NO_AP_FOUND) {
ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf);
} else {
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf,
format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason));
char bssid_s[18];
format_mac_addr_upper(it.bssid, bssid_s);
ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s,
get_disconnect_reason_str(it.reason));
}
uint8_t reason = it.reason;
@@ -434,7 +436,7 @@ void WiFiComponent::wifi_scan_done_callback_() {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
return false;
@@ -484,7 +486,7 @@ bssid_t WiFiComponent::wifi_bssid() {
return bssid;
}
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); }
network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; }
network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; }

View File

@@ -72,7 +72,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); }
bool WiFiComponent::wifi_sta_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
if (!manual_ip.has_value()) {
return true;
}
@@ -146,7 +146,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> manual_ip) {
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esphome::network::IPAddress ip_address, gateway, subnet, dns;
if (manual_ip.has_value()) {
ip_address = manual_ip->static_ip;
@@ -200,7 +200,7 @@ bssid_t WiFiComponent::wifi_bssid() {
return bssid;
}
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); }
int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); }
network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.11.0b1"
__version__ = "2025.12.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -178,7 +178,6 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
TEMPLATABLE_VALUE(uint32_t, delay)
void play_complex(const Ts &...x) override {
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
this->num_running_++;
// If num_running_ > 1, we have multiple instances running in parallel
@@ -187,9 +186,22 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
// WARNING: This can accumulate delays if scripts are triggered faster than they complete!
// Users should set max_runs on parallel scripts to limit concurrent executions.
// Issue #10264: This is a workaround for parallel script delays interfering with each other.
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT,
/* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f),
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
// Optimization: For no-argument delays (most common case), use direct lambda
// instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution)
if constexpr (sizeof...(Ts) == 0) {
App.scheduler.set_timer_common_(
this, Scheduler::SchedulerItem::TIMEOUT,
/* is_static_string= */ true, "delay", this->delay_.value(), [this]() { this->play_next_(); },
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
} else {
// For delays with arguments, use std::bind to preserve argument values
// Arguments must be copied because original references may be invalid after delay
auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT,
/* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f),
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
}
}
float get_setup_priority() const override { return setup_priority::HARDWARE; }
@@ -412,7 +424,12 @@ template<typename... Ts> class WaitUntilAction : public Action<Ts...>, public Co
void setup() override {
// Start with loop disabled - only enable when there's work to do
this->disable_loop();
// IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already
// called before our setup() (e.g., from on_boot trigger at same priority level)
// and we must not undo its enable_loop() call
if (this->num_running_ == 0) {
this->disable_loop();
}
}
void play_complex(const Ts &...x) override {

View File

@@ -30,6 +30,7 @@ from esphome.const import (
from esphome.core import CORE, EsphomeError
from esphome.helpers import get_int_env, get_str_env
from esphome.log import AnsiFore, color
from esphome.types import ConfigType
from esphome.util import safe_print
_LOGGER = logging.getLogger(__name__)
@@ -154,8 +155,12 @@ def show_discover(config, username=None, password=None, client_id=None):
def get_esphome_device_ip(
config, username=None, password=None, client_id=None, timeout=25
):
config: ConfigType,
username: str | None = None,
password: str | None = None,
client_id: str | None = None,
timeout: int | float = 25,
) -> list[str]:
if CONF_MQTT not in config:
raise EsphomeError(
"Cannot discover IP via MQTT as the config does not include the mqtt: "
@@ -166,6 +171,10 @@ def get_esphome_device_ip(
"Cannot discover IP via MQTT as the config does not include the device name: "
"component"
)
if not config[CONF_MQTT].get(CONF_BROKER):
raise EsphomeError(
"Cannot discover IP via MQTT as the broker is not configured"
)
dev_name = config[CONF_ESPHOME][CONF_NAME]
dev_ip = None

View File

@@ -1,11 +1,11 @@
pylint==4.0.2
pylint==4.0.3
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.4 # also change in .pre-commit-config.yaml when updating
ruff==0.14.5 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating
pre-commit
# Unit tests
pytest==9.0.0
pytest==9.0.1
pytest-cov==7.0.0
pytest-mock==3.15.1
pytest-asyncio==1.3.0

View File

@@ -1,2 +0,0 @@
packages:
common: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
<<: !include common.yaml

View File

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

View File

@@ -0,0 +1,27 @@
esp32:
variant: esp32p4
flash_size: 32MB
cpu_frequency: 400MHz
framework:
type: esp-idf
advanced:
enable_idf_experimental_features: yes
ota:
platform: esphome
wifi:
ssid: MySSID
password: password1
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
d1_pin: GPIO10
d2_pin: GPIO9
d3_pin: GPIO8

View File

@@ -1,15 +0,0 @@
packages:
common: !include common.yaml
matrix_keypad:
id: keypad
rows:
- pin: 10
- pin: 11
columns:
- pin: 12
- pin: 13
keys: "1234"
has_pulldowns: true
on_key:
- lambda: ESP_LOGI("KEY", "key %d pressed", x);

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -15,6 +15,7 @@ nrf52:
inverted: true
mode:
output: true
dcdc: False
reg0:
voltage: 2.1V
uicr_erase: true

View File

@@ -1,4 +0,0 @@
substitutions:
pin: GPIO4
<<: !include common.yaml

View File

@@ -1,4 +0,0 @@
substitutions:
pin: GPIO1
<<: !include common.yaml

View File

@@ -1,2 +0,0 @@
packages:
common: !include common.yaml

View File

@@ -1,8 +0,0 @@
substitutions:
scl_pin: GPIO40
sda_pin: GPIO41
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,2 +0,0 @@
packages:
common: !include common.yaml

View File

@@ -1,48 +0,0 @@
<<: !include ../logger/common-usb_serial_jtag.yaml
esphome:
on_boot:
then:
- uart.write:
id: uart_1
data: 'Hello World'
- uart.write:
id: uart_1
data: [0x00, 0x20, 0x42]
uart:
- id: uart_1
tx_pin: 4
rx_pin: 5
flow_control_pin: 6
baud_rate: 9600
data_bits: 8
rx_buffer_size: 512
rx_full_threshold: 10
rx_timeout: 1
parity: EVEN
stop_bits: 2
- id: uart_2
tx_pin: 7
rx_pin: 8
flow_control_pin: 9
baud_rate: 9600
data_bits: 8
rx_buffer_size: 512
rx_full_threshold: 10
rx_timeout: 1
parity: EVEN
stop_bits: 2
- id: uart_3
tx_pin: 10
rx_pin: 11
flow_control_pin: 12
baud_rate: 9600
data_bits: 8
rx_buffer_size: 512
rx_full_threshold: 10
rx_timeout: 1
parity: EVEN
stop_bits: 2

View File

@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO40
sda_pin: GPIO41
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,11 +0,0 @@
substitutions:
clk_pin: GPIO40
miso_pin: GPIO41
mosi_pin: GPIO6
cs_pin: GPIO19
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO40
sda_pin: GPIO41
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,11 +0,0 @@
substitutions:
clk_pin: GPIO40
miso_pin: GPIO41
mosi_pin: GPIO6
cs_pin: GPIO19
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO40
sda_pin: GPIO41
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,11 +0,0 @@
substitutions:
clk_pin: GPIO40
miso_pin: GPIO41
mosi_pin: GPIO6
cs_pin: GPIO19
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO40
sda_pin: GPIO41
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,11 +0,0 @@
substitutions:
clk_pin: GPIO40
miso_pin: GPIO41
mosi_pin: GPIO6
cs_pin: GPIO19
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -14,6 +14,7 @@ climate:
id: test_thermostat
name: Test Thermostat Custom Modes
sensor: thermostat_sensor
default_preset: "Eco Plus"
preset:
- name: Away
default_target_temperature_low: 16°C

View File

@@ -0,0 +1,47 @@
# Test for wait_until in on_boot automation
# Reproduces bug where wait_until in on_boot would hang forever
# because WaitUntilAction::setup() would disable_loop() after
# play_complex() had already enabled it.
esphome:
name: wait-until-on-boot
on_boot:
then:
- logger.log: "on_boot: Starting wait_until test"
- globals.set:
id: on_boot_started
value: 'true'
- wait_until:
condition:
lambda: return id(test_flag);
timeout: 5s
- logger.log: "on_boot: wait_until completed successfully"
host:
logger:
level: DEBUG
globals:
- id: on_boot_started
type: bool
initial_value: 'false'
- id: test_flag
type: bool
initial_value: 'false'
api:
actions:
- action: set_test_flag
then:
- globals.set:
id: test_flag
value: 'true'
- action: check_on_boot_started
then:
- lambda: |-
if (id(on_boot_started)) {
ESP_LOGI("test", "on_boot has started");
} else {
ESP_LOGI("test", "on_boot has NOT started");
}

View File

@@ -2,9 +2,13 @@
from __future__ import annotations
from aioesphomeapi import ClimateInfo, ClimatePreset
import asyncio
import aioesphomeapi
from aioesphomeapi import ClimateInfo, ClimatePreset, EntityState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -14,15 +18,50 @@ async def test_climate_custom_fan_modes_and_presets(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that custom presets are properly exposed via API."""
"""Test that custom presets are properly exposed and can be changed."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
# Get entities and services
states: dict[int, EntityState] = {}
super_saver_future: asyncio.Future[EntityState] = loop.create_future()
vacation_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
if isinstance(state, aioesphomeapi.ClimateState):
# Wait for Super Saver preset
if (
state.custom_preset == "Super Saver"
and state.target_temperature_low == 20.0
and state.target_temperature_high == 24.0
and not super_saver_future.done()
):
super_saver_future.set_result(state)
# Wait for Vacation Mode preset
elif (
state.custom_preset == "Vacation Mode"
and state.target_temperature_low == 15.0
and state.target_temperature_high == 18.0
and not vacation_future.done()
):
vacation_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Verify enum presets are exposed (from preset: config map)
assert ClimatePreset.AWAY in test_climate.supported_presets, (
"Expected AWAY in enum presets"
@@ -40,3 +79,43 @@ async def test_climate_custom_fan_modes_and_presets(
assert "Vacation Mode" in custom_presets, (
"Expected 'Vacation Mode' in custom presets"
)
# Get initial state and verify default preset
initial_state = initial_state_helper.initial_states.get(test_climate.key)
assert initial_state is not None, "Climate initial state not found"
assert isinstance(initial_state, aioesphomeapi.ClimateState)
assert initial_state.custom_preset == "Eco Plus", (
f"Expected default preset 'Eco Plus', got '{initial_state.custom_preset}'"
)
assert initial_state.target_temperature_low == 18.0, (
f"Expected low temp 18.0, got {initial_state.target_temperature_low}"
)
assert initial_state.target_temperature_high == 22.0, (
f"Expected high temp 22.0, got {initial_state.target_temperature_high}"
)
# Test changing to "Super Saver" custom preset
client.climate_command(test_climate.key, custom_preset="Super Saver")
try:
super_saver_state = await asyncio.wait_for(super_saver_future, timeout=5.0)
except TimeoutError:
pytest.fail("Super Saver preset change not received within 5 seconds")
assert isinstance(super_saver_state, aioesphomeapi.ClimateState)
assert super_saver_state.custom_preset == "Super Saver"
assert super_saver_state.target_temperature_low == 20.0
assert super_saver_state.target_temperature_high == 24.0
# Test changing to "Vacation Mode" custom preset
client.climate_command(test_climate.key, custom_preset="Vacation Mode")
try:
vacation_state = await asyncio.wait_for(vacation_future, timeout=5.0)
except TimeoutError:
pytest.fail("Vacation Mode preset change not received within 5 seconds")
assert isinstance(vacation_state, aioesphomeapi.ClimateState)
assert vacation_state.custom_preset == "Vacation Mode"
assert vacation_state.target_temperature_low == 15.0
assert vacation_state.target_temperature_high == 18.0

View File

@@ -0,0 +1,91 @@
"""Integration test for wait_until in on_boot automation.
This test validates that wait_until works correctly when triggered from on_boot,
which runs at the same setup priority as WaitUntilAction itself. This was broken
before the fix because WaitUntilAction::setup() would unconditionally disable_loop(),
even if play_complex() had already been called and enabled the loop.
The bug: on_boot fires during StartupTrigger::setup(), which calls WaitUntilAction::play_complex()
before WaitUntilAction::setup() has run. Then when WaitUntilAction::setup() runs, it calls
disable_loop(), undoing the enable_loop() from play_complex(), causing wait_until to hang forever.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_wait_until_on_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that wait_until works in on_boot automation with a condition that becomes true later."""
loop = asyncio.get_running_loop()
on_boot_started = False
on_boot_completed = False
on_boot_started_pattern = re.compile(r"on_boot: Starting wait_until test")
on_boot_complete_pattern = re.compile(r"on_boot: wait_until completed successfully")
on_boot_started_future = loop.create_future()
on_boot_complete_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for test progress."""
nonlocal on_boot_started, on_boot_completed
if on_boot_started_pattern.search(line):
on_boot_started = True
if not on_boot_started_future.done():
on_boot_started_future.set_result(True)
if on_boot_complete_pattern.search(line):
on_boot_completed = True
if not on_boot_complete_future.done():
on_boot_complete_future.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Wait for on_boot to start
await asyncio.wait_for(on_boot_started_future, timeout=10.0)
assert on_boot_started, "on_boot did not start"
# At this point, on_boot is blocked in wait_until waiting for test_flag to become true
# If the bug exists, wait_until's loop is disabled and it will never complete
# even after we set the flag
# Give a moment for setup to complete
await asyncio.sleep(0.5)
# Now set the flag that wait_until is waiting for
_, services = await client.list_entities_services()
set_flag_service = next(
(s for s in services if s.name == "set_test_flag"), None
)
assert set_flag_service is not None, "set_test_flag service not found"
client.execute_service(set_flag_service, {})
# If the fix works, wait_until's loop() will check the condition and proceed
# If the bug exists, wait_until is stuck with disabled loop and will timeout
try:
await asyncio.wait_for(on_boot_complete_future, timeout=2.0)
assert on_boot_completed, (
"on_boot wait_until did not complete after flag was set"
)
except TimeoutError:
pytest.fail(
"wait_until in on_boot did not complete within 2s after condition became true. "
"This indicates the bug where WaitUntilAction::setup() disables the loop "
"after play_complex() has already enabled it."
)

View File

@@ -1166,6 +1166,56 @@ def test_upload_program_ota_with_mqtt_resolution(
)
def test_upload_program_ota_with_mqtt_empty_broker(
mock_mqtt_get_ip: Mock,
mock_is_ip_address: Mock,
mock_run_ota: Mock,
tmp_path: Path,
caplog: CaptureFixture,
) -> None:
"""Test upload_program with OTA when MQTT broker is empty (issue #11653)."""
setup_core(address="192.168.1.50", platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_is_ip_address.return_value = True
mock_mqtt_get_ip.side_effect = EsphomeError(
"Cannot discover IP via MQTT as the broker is not configured"
)
mock_run_ota.return_value = (0, "192.168.1.50")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
],
CONF_MQTT: {
CONF_BROKER: "",
},
CONF_MDNS: {
CONF_DISABLED: True,
},
}
args = MockArgs(username="user", password="pass", client_id="client")
devices = ["MQTTIP", "192.168.1.50"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.50"
# Verify MQTT was attempted but failed gracefully
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
# Verify we fell back to the IP address
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.50"], 3232, None, expected_firmware
)
# Verify warning was logged
assert "MQTT IP discovery failed" in caplog.text
@patch("esphome.__main__.importlib.import_module")
def test_upload_program_platform_specific_handler(
mock_import: Mock,

View File

@@ -0,0 +1,91 @@
"""Unit tests for esphome.mqtt module."""
from __future__ import annotations
import pytest
from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME
from esphome.core import EsphomeError
from esphome.mqtt import get_esphome_device_ip
def test_get_esphome_device_ip_empty_broker() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when broker is empty."""
config = {
CONF_MQTT: {
CONF_BROKER: "",
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the broker is not configured",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_none_broker() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when broker is None."""
config = {
CONF_MQTT: {
CONF_BROKER: None,
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the broker is not configured",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_mqtt() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when mqtt config is missing."""
config = {
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the mqtt:",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_esphome() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when esphome config is missing."""
config = {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the device name:",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_name() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when device name is missing."""
config = {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
CONF_ESPHOME: {},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the device name:",
):
get_esphome_device_ip(config)