From e199145f1c3b168e8584c4613f1d814c1694a4f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 12:20:55 -0600 Subject: [PATCH 01/12] [core] Avoid expensive modulo in LockFreeQueue for non-power-of-2 sizes (#14221) --- esphome/core/lock_free_queue.h | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index e96b739b58..522fbd36e1 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -38,13 +38,27 @@ template class LockFreeQueue { } protected: + // Advance ring buffer index by one, wrapping at SIZE. + // Power-of-2 sizes use modulo (compiler emits single mask instruction). + // Non-power-of-2 sizes use comparison to avoid expensive multiply-shift sequences. + static constexpr uint8_t next_index(uint8_t index) { + if constexpr ((SIZE & (SIZE - 1)) == 0) { + return (index + 1) % SIZE; + } else { + uint8_t next = index + 1; + if (next >= SIZE) [[unlikely]] + next = 0; + return next; + } + } + // Internal push that reports queue state - for use by derived classes bool push_internal_(T *element, bool &was_empty, uint8_t &old_tail) { if (element == nullptr) return false; uint8_t current_tail = tail_.load(std::memory_order_relaxed); - uint8_t next_tail = (current_tail + 1) % SIZE; + uint8_t next_tail = next_index(current_tail); // Read head before incrementing tail uint8_t head_before = head_.load(std::memory_order_acquire); @@ -73,14 +87,21 @@ template class LockFreeQueue { } T *element = buffer_[current_head]; - head_.store((current_head + 1) % SIZE, std::memory_order_release); + head_.store(next_index(current_head), std::memory_order_release); return element; } size_t size() const { uint8_t tail = tail_.load(std::memory_order_acquire); uint8_t head = head_.load(std::memory_order_acquire); - return (tail - head + SIZE) % SIZE; + if constexpr ((SIZE & (SIZE - 1)) == 0) { + return (tail - head + SIZE) % SIZE; + } else { + int diff = static_cast(tail) - static_cast(head); + if (diff < 0) + diff += SIZE; + return static_cast(diff); + } } uint16_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); } @@ -90,7 +111,7 @@ template class LockFreeQueue { bool empty() const { return head_.load(std::memory_order_acquire) == tail_.load(std::memory_order_acquire); } bool full() const { - uint8_t next_tail = (tail_.load(std::memory_order_relaxed) + 1) % SIZE; + uint8_t next_tail = next_index(tail_.load(std::memory_order_relaxed)); return next_tail == head_.load(std::memory_order_acquire); } From 0d32a5321ca20943eea2d5c9c9d3b3aeac44b568 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:46:53 -0500 Subject: [PATCH 02/12] [remote_transmitter/remote_receiver] Rename _esp32.cpp to _rmt.cpp (#14226) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/remote_receiver/__init__.py | 2 +- .../{remote_receiver_esp32.cpp => remote_receiver_rmt.cpp} | 0 esphome/components/remote_transmitter/__init__.py | 2 +- ...{remote_transmitter_esp32.cpp => remote_transmitter_rmt.cpp} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename esphome/components/remote_receiver/{remote_receiver_esp32.cpp => remote_receiver_rmt.cpp} (100%) rename esphome/components/remote_transmitter/{remote_transmitter_esp32.cpp => remote_transmitter_rmt.cpp} (100%) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 362f6e99db..53a0f8fb77 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -237,7 +237,7 @@ async def to_code(config): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "remote_receiver_esp32.cpp": { + "remote_receiver_rmt.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, diff --git a/esphome/components/remote_receiver/remote_receiver_esp32.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp similarity index 100% rename from esphome/components/remote_receiver/remote_receiver_esp32.cpp rename to esphome/components/remote_receiver/remote_receiver_rmt.cpp diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index fc772f88b2..371dbb685f 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -171,7 +171,7 @@ async def to_code(config): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "remote_transmitter_esp32.cpp": { + "remote_transmitter_rmt.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, diff --git a/esphome/components/remote_transmitter/remote_transmitter_esp32.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp similarity index 100% rename from esphome/components/remote_transmitter/remote_transmitter_esp32.cpp rename to esphome/components/remote_transmitter/remote_transmitter_rmt.cpp From daee71a2c17871b1f23e782059ae9e906abeb9f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:21:29 -0500 Subject: [PATCH 03/12] [http_request] Retry update check on startup until network is ready (#14228) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../update/http_request_update.cpp | 24 ++++++++++++++++++- .../http_request/update/http_request_update.h | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 85609bd31f..1900f69a69 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -24,8 +24,29 @@ namespace http_request { static const char *const TAG = "http_request.update"; static const size_t MAX_READ_SIZE = 256; +static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; +static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000; +static constexpr uint8_t INITIAL_CHECK_MAX_ATTEMPTS = 6; -void HttpRequestUpdate::setup() { this->ota_parent_->add_state_listener(this); } +void HttpRequestUpdate::setup() { + this->ota_parent_->add_state_listener(this); + + // Check periodically until network is ready + // Only if update interval is > total retry window to avoid redundant checks + if (this->get_update_interval() != SCHEDULER_DONT_RUN && + this->get_update_interval() > INITIAL_CHECK_INTERVAL_MS * INITIAL_CHECK_MAX_ATTEMPTS) { + this->initial_check_remaining_ = INITIAL_CHECK_MAX_ATTEMPTS; + this->set_interval(INITIAL_CHECK_INTERVAL_ID, INITIAL_CHECK_INTERVAL_MS, [this]() { + bool connected = network::is_connected(); + if (--this->initial_check_remaining_ == 0 || connected) { + this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); + if (connected) { + this->update(); + } + } + }); + } +} void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_t error) { if (state == ota::OTAState::OTA_IN_PROGRESS) { @@ -45,6 +66,7 @@ void HttpRequestUpdate::update() { ESP_LOGD(TAG, "Network not connected, skipping update check"); return; } + this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index cf34ace18e..b8350346f9 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -40,6 +40,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif + uint8_t initial_check_remaining_{0}; }; } // namespace http_request From 063c6a9e45576eb5b22c94977cddac1f1eab1aef Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 23 Feb 2026 21:06:20 +0100 Subject: [PATCH 04/12] [esp32,core] Move CONF_ENABLE_OTA_ROLLBACK to core (#14231) --- esphome/components/esp32/__init__.py | 2 +- esphome/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4c211b2f2a..62367443da 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_BOARD, CONF_COMPONENTS, CONF_DISABLED, + CONF_ENABLE_OTA_ROLLBACK, CONF_ESPHOME, CONF_FRAMEWORK, CONF_IGNORE_EFUSE_CUSTOM_MAC, @@ -90,7 +91,6 @@ CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" -CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_RELEASE = "release" diff --git a/esphome/const.py b/esphome/const.py index ccc9d56dbb..0b1037d091 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -354,6 +354,7 @@ CONF_ELSE = "else" CONF_ENABLE_BTM = "enable_btm" CONF_ENABLE_IPV6 = "enable_ipv6" CONF_ENABLE_ON_BOOT = "enable_on_boot" +CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_ENABLE_PIN = "enable_pin" CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access" CONF_ENABLE_RRM = "enable_rrm" From 918bbfb0d3c73be7616fc7e7ab57238f27bc35ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:22:32 +0000 Subject: [PATCH 05/12] Bump aioesphomeapi from 44.0.0 to 44.1.0 (#14232) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index be3445dceb..d22097b3ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.1.7 esphome-dashboard==20260210.0 -aioesphomeapi==44.0.0 +aioesphomeapi==44.1.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 02c37bb6d63d9ccb457a13f35b6f2e3c80c35cc9 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 23 Feb 2026 21:23:40 +0100 Subject: [PATCH 06/12] [nrf52,logger] generate crash magic in python (#14173) --- esphome/components/logger/logger_zephyr.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index c2d24d6efc..6b46b93c61 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -20,8 +20,6 @@ __attribute__((weak)) void print_coredump() {} namespace esphome::logger { -static const uint32_t CRASH_MAGIC = 0xDEADBEEF; - __attribute__((section(".noinit"))) struct { uint32_t magic; uint32_t reason; @@ -152,7 +150,7 @@ static const char *reason_to_str(unsigned int reason, char *buf) { void Logger::dump_crash_() { ESP_LOGD(TAG, "Crash buffer address %p", &crash_buf); - if (crash_buf.magic == CRASH_MAGIC) { + if (crash_buf.magic == App.get_config_hash()) { char reason_buf[REASON_BUF_SIZE]; ESP_LOGE(TAG, "Last crash:"); ESP_LOGE(TAG, "Reason=%s PC=0x%08x LR=0x%08x", reason_to_str(crash_buf.reason, reason_buf), crash_buf.pc, @@ -164,7 +162,7 @@ void Logger::dump_crash_() { } void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) { - crash_buf.magic = CRASH_MAGIC; + crash_buf.magic = App.get_config_hash(); crash_buf.reason = reason; if (esf) { crash_buf.pc = esf->basic.pc; From db6db5fb1061956513aee35e0b45b2c4b9ce3a3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 14:25:57 -0600 Subject: [PATCH 07/12] merge proto --- esphome/components/api/api.proto | 28 ++++++++++ esphome/components/api/api_connection.cpp | 25 ++++++++- esphome/components/api/api_pb2.cpp | 54 +++++++++++++++++++ esphome/components/api/api_pb2.h | 38 +++++++++++++- esphome/components/api/api_pb2_dump.cpp | 39 ++++++++++++++ esphome/components/time/__init__.py | 64 +++++++++++++++++++++-- 6 files changed, 241 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 18dac6a2d1..4437617354 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -834,6 +834,33 @@ message GetTimeRequest { option (source) = SOURCE_SERVER; } +enum DSTRuleType { + DST_RULE_TYPE_NONE = 0; + DST_RULE_TYPE_MONTH_WEEK_DAY = 1; + DST_RULE_TYPE_JULIAN_NO_LEAP = 2; + DST_RULE_TYPE_DAY_OF_YEAR = 3; +} + +message DSTRule { + option (source) = SOURCE_CLIENT; + + sint32 time_seconds = 1; + uint32 day = 2; + DSTRuleType type = 3; + uint32 month = 4; + uint32 week = 5; + uint32 day_of_week = 6; +} + +message ParsedTimezone { + option (source) = SOURCE_CLIENT; + + sint32 std_offset_seconds = 1; + sint32 dst_offset_seconds = 2; + DSTRule dst_start = 3; + DSTRule dst_end = 4; +} + message GetTimeResponse { option (id) = 37; option (source) = SOURCE_CLIENT; @@ -841,6 +868,7 @@ message GetTimeResponse { fixed32 epoch_seconds = 1; string timezone = 2; + ParsedTimezone parsed_timezone = 3; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7bc9c45c05..a10e53d5ee 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1113,7 +1113,30 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE if (!value.timezone.empty()) { - homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); + // Check if the sender provided pre-parsed timezone data. + // If std_offset is non-zero or DST rules are present, the parsed data was populated. + // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent. + const auto &pt = value.parsed_timezone; + if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { + time::ParsedTimezone tz{}; + tz.std_offset_seconds = pt.std_offset_seconds; + tz.dst_offset_seconds = pt.dst_offset_seconds; + tz.dst_start.time_seconds = pt.dst_start.time_seconds; + tz.dst_start.day = static_cast(pt.dst_start.day); + tz.dst_start.type = static_cast(pt.dst_start.type); + tz.dst_start.month = static_cast(pt.dst_start.month); + tz.dst_start.week = static_cast(pt.dst_start.week); + tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); + tz.dst_end.time_seconds = pt.dst_end.time_seconds; + tz.dst_end.day = static_cast(pt.dst_end.day); + tz.dst_end.type = static_cast(pt.dst_end.type); + tz.dst_end.month = static_cast(pt.dst_end.month); + tz.dst_end.week = static_cast(pt.dst_end.week); + tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); + time::set_global_tz(tz); + } else { + homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); + } } #endif } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5c50a8aa5b..9e74d5ddc7 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -954,12 +954,66 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel return true; } #endif +bool DSTRule::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->time_seconds = value.as_sint32(); + break; + case 2: + this->day = value.as_uint32(); + break; + case 3: + this->type = static_cast(value.as_uint32()); + break; + case 4: + this->month = value.as_uint32(); + break; + case 5: + this->week = value.as_uint32(); + break; + case 6: + this->day_of_week = value.as_uint32(); + break; + default: + return false; + } + return true; +} +bool ParsedTimezone::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { + case 1: + this->std_offset_seconds = value.as_sint32(); + break; + case 2: + this->dst_offset_seconds = value.as_sint32(); + break; + default: + return false; + } + return true; +} +bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { + switch (field_id) { + case 3: + value.decode_to_message(this->dst_start); + break; + case 4: + value.decode_to_message(this->dst_end); + break; + default: + return false; + } + return true; +} bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: { this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); break; } + case 3: + value.decode_to_message(this->parsed_timezone); + break; default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index c90873d993..2882868982 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -64,6 +64,12 @@ enum LogLevel : uint32_t { LOG_LEVEL_VERBOSE = 6, LOG_LEVEL_VERY_VERBOSE = 7, }; +enum DSTRuleType : uint32_t { + DST_RULE_TYPE_NONE = 0, + DST_RULE_TYPE_MONTH_WEEK_DAY = 1, + DST_RULE_TYPE_JULIAN_NO_LEAP = 2, + DST_RULE_TYPE_DAY_OF_YEAR = 3, +}; #ifdef USE_API_USER_DEFINED_ACTIONS enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_BOOL = 0, @@ -1116,15 +1122,45 @@ class GetTimeRequest final : public ProtoMessage { protected: }; +class DSTRule final : public ProtoDecodableMessage { + public: + int32_t time_seconds{0}; + uint32_t day{0}; + enums::DSTRuleType type{}; + uint32_t month{0}; + uint32_t week{0}; + uint32_t day_of_week{0}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +class ParsedTimezone final : public ProtoDecodableMessage { + public: + int32_t std_offset_seconds{0}; + int32_t dst_offset_seconds{0}; + DSTRule dst_start{}; + DSTRule dst_end{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 14; + static constexpr uint8_t ESTIMATED_SIZE = 31; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "get_time_response"; } #endif uint32_t epoch_seconds{0}; StringRef timezone{}; + ParsedTimezone parsed_timezone{}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 73690610ed..290e5fe4d2 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -208,6 +208,20 @@ template<> const char *proto_enum_to_string(enums::LogLevel val return "UNKNOWN"; } } +template<> const char *proto_enum_to_string(enums::DSTRuleType value) { + switch (value) { + case enums::DST_RULE_TYPE_NONE: + return "DST_RULE_TYPE_NONE"; + case enums::DST_RULE_TYPE_MONTH_WEEK_DAY: + return "DST_RULE_TYPE_MONTH_WEEK_DAY"; + case enums::DST_RULE_TYPE_JULIAN_NO_LEAP: + return "DST_RULE_TYPE_JULIAN_NO_LEAP"; + case enums::DST_RULE_TYPE_DAY_OF_YEAR: + return "DST_RULE_TYPE_DAY_OF_YEAR"; + default: + return "UNKNOWN"; + } +} #ifdef USE_API_USER_DEFINED_ACTIONS template<> const char *proto_enum_to_string(enums::ServiceArgType value) { switch (value) { @@ -1252,10 +1266,35 @@ const char *GetTimeRequest::dump_to(DumpBuffer &out) const { out.append("GetTimeRequest {}"); return out.c_str(); } +const char *DSTRule::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "DSTRule"); + dump_field(out, "time_seconds", this->time_seconds); + dump_field(out, "day", this->day); + dump_field(out, "type", static_cast(this->type)); + dump_field(out, "month", this->month); + dump_field(out, "week", this->week); + dump_field(out, "day_of_week", this->day_of_week); + return out.c_str(); +} +const char *ParsedTimezone::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "ParsedTimezone"); + dump_field(out, "std_offset_seconds", this->std_offset_seconds); + dump_field(out, "dst_offset_seconds", this->dst_offset_seconds); + out.append(" dst_start: "); + this->dst_start.dump_to(out); + out.append("\n"); + out.append(" dst_end: "); + this->dst_end.dump_to(out); + out.append("\n"); + return out.c_str(); +} const char *GetTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); dump_field(out, "timezone", this->timezone); + out.append(" parsed_timezone: "); + this->parsed_timezone.dump_to(out); + out.append("\n"); return out.c_str(); } #ifdef USE_API_USER_DEFINED_ACTIONS diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a20d79b857..8a33e042ee 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,6 +1,10 @@ from importlib import resources import logging +from aioesphomeapi.posix_tz import ( + DSTRuleType as PyDSTRuleType, + parse_posix_tz as parse_posix_tz_python, +) import tzlocal from esphome import automation @@ -39,6 +43,19 @@ CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Co SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) +# C++ types for pre-parsed timezone struct generation +DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) +DSTRule_cpp = time_ns.struct("DSTRule") +ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") + +# Map Python DSTRuleType enum values to C++ enum expressions +_DST_RULE_TYPE_MAP = { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, +} + def _load_tzdata(iana_key: str) -> bytes | None: # From https://tzdata.readthedocs.io/en/latest/#examples @@ -260,11 +277,16 @@ def validate_tz(value: str) -> str: value = cv.string_strict(value) tzfile = _load_tzdata(value) - if tzfile is None: - # Not a IANA key, probably a TZ string - return value + if tzfile is not None: + value = _extract_tz_string(tzfile) - return _extract_tz_string(tzfile) + # Validate that the POSIX TZ string is parseable + try: + parse_posix_tz_python(value) + except ValueError as e: + raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + + return value TIME_SCHEMA = cv.Schema( @@ -305,11 +327,43 @@ TIME_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("15min")) +def _build_dst_rule(rule): + """Build a cg.StructInitializer for a DSTRule from a Python DSTRule.""" + return cg.StructInitializer( + DSTRule_cpp, + ("time_seconds", rule.time_seconds), + ("day", rule.day), + ("type", _DST_RULE_TYPE_MAP[rule.type]), + ("month", rule.month), + ("week", rule.week), + ("day_of_week", rule.day_of_week), + ) + + +def _build_parsed_timezone_struct(parsed): + """Build a cg.StructInitializer for a ParsedTimezone from a Python ParsedTimezone.""" + return cg.StructInitializer( + ParsedTimezone_cpp, + ("std_offset_seconds", parsed.std_offset_seconds), + ("dst_offset_seconds", parsed.dst_offset_seconds), + ("dst_start", _build_dst_rule(parsed.dst_start)), + ("dst_end", _build_dst_rule(parsed.dst_end)), + ) + + async def setup_time_core_(time_var, config): if timezone := config.get(CONF_TIMEZONE): - cg.add(time_var.set_timezone(timezone)) cg.add_define("USE_TIME_TIMEZONE") + if CORE.is_host: + # Host platform needs setenv("TZ")/tzset() for libc compatibility + cg.add(time_var.set_timezone(timezone)) + else: + # Embedded: pre-parse at codegen time, emit struct directly + parsed = parse_posix_tz_python(timezone) + tz_struct = _build_parsed_timezone_struct(parsed) + cg.add(time_ns.set_global_tz(tz_struct)) + for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) From de01d766f1e2e68709c27db417f67e7329d54334 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 14:43:57 -0600 Subject: [PATCH 08/12] [time] Mark posix_tz parser as bridge code to remove before 2026.9.0 The C++ POSIX TZ string parser is only needed for backward compatibility with older Home Assistant clients that send the timezone as a string. Once all clients send the pre-parsed ParsedTimezone protobuf struct, the parser and its helpers can be removed entirely. See https://github.com/esphome/backlog/issues/91 --- esphome/components/time/posix_tz.cpp | 9 ++++++++- esphome/components/time/posix_tz.h | 14 +++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 8c94d1d3e0..f811dd7989 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -17,7 +17,8 @@ const ParsedTimezone &get_global_tz() { return global_tz_; } namespace internal { -// Helper to parse an unsigned integer from string, updating pointer +// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule, +// and parse_transition_time are only used by parse_posix_tz() (bridge code). static uint32_t parse_uint(const char *&p) { uint32_t value = 0; while (std::isdigit(static_cast(*p))) { @@ -364,6 +365,12 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } +// Remove before 2026.9.0: This parser is bridge code for backward compatibility with +// older Home Assistant clients that send the timezone as a POSIX TZ string instead of +// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct +// directly, this function and the parsing helpers above (skip_tz_name, parse_offset, +// parse_dst_rule, parse_transition_time) can be removed. +// See https://github.com/esphome/backlog/issues/91 bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { if (!tz_string || !*tz_string) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 5446ddb9df..c71ba15cd1 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -37,6 +37,14 @@ struct ParsedTimezone { }; /// Parse a POSIX TZ string into a ParsedTimezone struct. +/// +/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). +/// This parser only exists so that older Home Assistant clients that send the timezone +/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still +/// set the timezone on the device. Once all clients are updated to send the struct +/// directly, this function and all internal parsing helpers will be removed. +/// See https://github.com/esphome/backlog/issues/91 +/// /// Supports formats like: /// - "EST5" (simple offset, no DST) /// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules) @@ -72,7 +80,11 @@ const ParsedTimezone &get_global_tz(); /// @return true if DST is in effect at the given time bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); -// Internal helper functions exposed for testing +// Internal helper functions exposed for testing. +// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only +// used by parse_posix_tz() which is bridge code for backward compatibility. +// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.) +// are used by the conversion functions and will stay. namespace internal { From 1a99abc6292900b27bdb1d613f8cce4403e8bcca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 14:45:37 -0600 Subject: [PATCH 09/12] [time] Add context to test file about bridge code removal timeline --- tests/components/time/posix_tz_parser.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 301136175a..d1747ef5b1 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1,5 +1,14 @@ -// Tests for the POSIX TZ parser and ESPTime::strptime implementations -// These custom parsers avoid pulling in the scanf family, saving ~9.8KB on ESP32-IDF. +// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime. +// +// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is +// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0. +// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely +// used to handle timezone strings from Home Assistant clients older than 2026.3.0 +// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct. +// See https://github.com/esphome/backlog/issues/91 +// +// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion +// functions that will remain permanently. // Enable USE_TIME_TIMEZONE for tests #define USE_TIME_TIMEZONE From ba11722e773f9c766edaacfb0bfc7a22dbe031d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 15:00:17 -0600 Subject: [PATCH 10/12] [time] Skip POSIX TZ validation for empty timezone strings Empty timezone strings are valid (meaning UTC/no timezone). The parse_posix_tz_python() validation should only run on non-empty strings. --- esphome/components/time/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 8a33e042ee..161a334999 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -280,11 +280,12 @@ def validate_tz(value: str) -> str: if tzfile is not None: value = _extract_tz_string(tzfile) - # Validate that the POSIX TZ string is parseable - try: - parse_posix_tz_python(value) - except ValueError as e: - raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + # Validate that the POSIX TZ string is parseable (skip empty strings) + if value: + try: + parse_posix_tz_python(value) + except ValueError as e: + raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e return value From 4b400aa79a7b61ee47bd5bdad7b77e808bdf9d7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 15:42:39 -0600 Subject: [PATCH 11/12] avoid ram increase --- esphome/components/time/__init__.py | 44 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 161a334999..851a400500 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -328,28 +328,29 @@ TIME_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("15min")) -def _build_dst_rule(rule): - """Build a cg.StructInitializer for a DSTRule from a Python DSTRule.""" - return cg.StructInitializer( - DSTRule_cpp, - ("time_seconds", rule.time_seconds), - ("day", rule.day), - ("type", _DST_RULE_TYPE_MAP[rule.type]), - ("month", rule.month), - ("week", rule.week), - ("day_of_week", rule.day_of_week), - ) +def _emit_dst_rule_fields(prefix, rule): + """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" + cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) + cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) + cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) + cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) -def _build_parsed_timezone_struct(parsed): - """Build a cg.StructInitializer for a ParsedTimezone from a Python ParsedTimezone.""" - return cg.StructInitializer( - ParsedTimezone_cpp, - ("std_offset_seconds", parsed.std_offset_seconds), - ("dst_offset_seconds", parsed.dst_offset_seconds), - ("dst_start", _build_dst_rule(parsed.dst_start)), - ("dst_end", _build_dst_rule(parsed.dst_end)), - ) +def _emit_parsed_timezone_fields(parsed): + """Emit field-by-field assignments for a local ParsedTimezone, then set_global_tz(). + + Uses individual assignments on a stack variable instead of a struct initializer + to keep constants as immediate operands in instructions (.irom0.text/flash) + rather than a const blob in .rodata (which maps to RAM on ESP8266). + """ + cg.add(cg.RawExpression("time::ParsedTimezone tz{}")) + cg.add(cg.RawExpression(f"tz.std_offset_seconds = {parsed.std_offset_seconds}")) + cg.add(cg.RawExpression(f"tz.dst_offset_seconds = {parsed.dst_offset_seconds}")) + _emit_dst_rule_fields("tz.dst_start", parsed.dst_start) + _emit_dst_rule_fields("tz.dst_end", parsed.dst_end) + cg.add(time_ns.set_global_tz(cg.RawExpression("tz"))) async def setup_time_core_(time_var, config): @@ -362,8 +363,7 @@ async def setup_time_core_(time_var, config): else: # Embedded: pre-parse at codegen time, emit struct directly parsed = parse_posix_tz_python(timezone) - tz_struct = _build_parsed_timezone_struct(parsed) - cg.add(time_ns.set_global_tz(tz_struct)) + _emit_parsed_timezone_fields(parsed) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) From a75783840837ea57d36ec85648e075d378a3a8b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 15:49:35 -0600 Subject: [PATCH 12/12] [time] Wrap codegen timezone fields in scope block Fixes redeclaration error when multiple time platforms are in the same build by wrapping the local ParsedTimezone variable in a scope block. Co-Authored-By: Claude Opus 4.6 --- esphome/components/time/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 851a400500..7ffa408db9 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -344,13 +344,16 @@ def _emit_parsed_timezone_fields(parsed): Uses individual assignments on a stack variable instead of a struct initializer to keep constants as immediate operands in instructions (.irom0.text/flash) rather than a const blob in .rodata (which maps to RAM on ESP8266). + Wrapped in a scope block to allow multiple time platforms in the same build. """ + cg.add(cg.RawStatement("{")) cg.add(cg.RawExpression("time::ParsedTimezone tz{}")) cg.add(cg.RawExpression(f"tz.std_offset_seconds = {parsed.std_offset_seconds}")) cg.add(cg.RawExpression(f"tz.dst_offset_seconds = {parsed.dst_offset_seconds}")) _emit_dst_rule_fields("tz.dst_start", parsed.dst_start) _emit_dst_rule_fields("tz.dst_end", parsed.dst_end) cg.add(time_ns.set_global_tz(cg.RawExpression("tz"))) + cg.add(cg.RawStatement("}")) async def setup_time_core_(time_var, config):