Compare commits

..

87 Commits

Author SHA1 Message Date
J. Nick Koston
637513694f Merge remote-tracking branch 'upstream/dev' into remove_posix_tz_parser
# Conflicts:
#	esphome/components/time/posix_tz.cpp
#	esphome/components/time/posix_tz.h
2026-04-01 18:46:04 -10:00
J. Nick Koston
176e9d43c9 update tests 2026-03-25 20:54:12 -10:00
J. Nick Koston
c55330c27e Merge remote-tracking branch 'upstream/dev' into remove_posix_tz_parser
# Conflicts:
#	esphome/components/api/api_pb2_dump.cpp
2026-03-25 20:33:26 -10:00
J. Nick Koston
9f7342dfaf Merge branch 'dev' into remove_posix_tz_parser 2026-03-19 15:39:47 -10:00
J. Nick Koston
4fbdf00bcb Merge branch 'dev' into remove_posix_tz_parser 2026-03-10 18:45:54 -10:00
J. Nick Koston
251e0129a2 Merge remote-tracking branch 'upstream/dev' into remove_posix_tz_parser 2026-03-01 17:02:06 -10:00
J. Nick Koston
8f0a555b31 Merge branch 'posix_tz_proto' into remove_posix_tz_parser 2026-02-26 15:20:11 -10:00
J. Nick Koston
32aad0f582 Merge remote-tracking branch 'upstream/dev' into posix_tz_proto 2026-02-26 15:15:50 -10:00
J. Nick Koston
0aa43b0c4f tweak 2026-02-23 16:51:46 -06:00
J. Nick Koston
28c6fbdc9e [api] Mark timezone string field as deprecated in GetTimeResponse
parsed_timezone struct should be used instead. The string field will be
removed before 2027.1.0.
2026-02-23 16:50:53 -06:00
J. Nick Koston
b4817c424d [api] Skip timezone update when parsed struct is not populated
Old clients (before 2026.3.0) send only the timezone string without the
parsed_timezone struct, so all fields default to zero. Without this check,
the device would overwrite its codegen-configured timezone with UTC.

Keep the codegen timezone when the struct is unpopulated (all zeros).
For actual UTC this also skips, which is harmless since UTC is the default.
2026-02-23 16:49:29 -06:00
J. Nick Koston
199288b813 [time] Fix test namespace for RecalcTimestampLocal and TimezoneOffset tests
Move tests that use make_us_central(), set_global_tz(), ParsedTimezone,
and DSTRuleType into esphome::time::testing namespace where those symbols
are declared.
2026-02-23 16:41:20 -06:00
J. Nick Koston
8374ccf7b5 [time] Remove C++ POSIX TZ string parser (bridge code)
Remove the runtime POSIX TZ string parser and all associated bridge code
now that timezone data is sent as pre-parsed structs via protobuf.

Removed:
- parse_posix_tz() and internal parsing helpers (skip_tz_name, parse_offset,
  parse_dst_rule, parse_uint, parse_transition_time)
- RealTimeClock::set_timezone() overloads and apply_timezone_()
- API connection fallback path for string-based timezone

Kept:
- All conversion functions (epoch_to_local_tm, is_in_dst, calculate_dst_transition)
- Internal helpers used by conversion functions
- localtime_r/localtime overrides
- Tests for all permanent functions
2026-02-23 16:34:14 -06:00
J. Nick Koston
a757838408 [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 <noreply@anthropic.com>
2026-02-23 15:49:35 -06:00
J. Nick Koston
4b400aa79a avoid ram increase 2026-02-23 15:42:39 -06:00
J. Nick Koston
ba11722e77 [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.
2026-02-23 15:00:25 -06:00
J. Nick Koston
49ddaa2002 Merge branch 'posix_tz' into posix_tz_proto 2026-02-23 14:49:09 -06:00
J. Nick Koston
1a99abc629 [time] Add context to test file about bridge code removal timeline 2026-02-23 14:45:37 -06:00
J. Nick Koston
f95d8a33e2 Merge branch 'posix_tz' into posix_tz_proto 2026-02-23 14:44:14 -06:00
J. Nick Koston
de01d766f1 [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
2026-02-23 14:43:57 -06:00
J. Nick Koston
db6db5fb10 merge proto 2026-02-23 14:25:57 -06:00
J. Nick Koston
9e8efe15d3 Merge branch 'dev' into posix_tz 2026-02-23 14:25:26 -06:00
J. Nick Koston
5e95b9b36c Merge branch 'dev' into posix_tz 2026-02-23 12:45:16 -06:00
J. Nick Koston
9c185b42c3 Reword comment to avoid ci-custom scanf lint false positive
The regex matches `scanf (` in comments too since `\s*\(` matches the
space before the parenthesized size note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 06:23:30 -06:00
J. Nick Koston
1fe95d8f82 Merge branch 'dev' into posix_tz 2026-02-12 05:40:07 -06:00
J. Nick Koston
849df4b2a8 no host 2026-01-30 03:26:03 -06:00
J. Nick Koston
5f7582ffdb override localtime() to use our timezone
By providing our own localtime() and localtime_r() implementations,
user lambdas calling ::localtime() continue to work correctly without
needing migration. This eliminates the breaking change while still
achieving the memory savings.
2026-01-30 03:25:21 -06:00
J. Nick Koston
dcd0f53027 fix clang-tidy warnings
- Add NOLINT for intentional global mutable state
- Simplify boolean return in parse_posix_tz
- Add USE_TIME_TIMEZONE define for tests
- Add NOLINT for Google Test SetUp/TearDown methods
2026-01-30 02:51:36 -06:00
J. Nick Koston
b5e073bf7f clarify comment about days_to_year_start 2026-01-30 01:52:05 -06:00
J. Nick Koston
cde2199b64 more cover 2026-01-30 01:46:57 -06:00
J. Nick Koston
a1eef9870c cleanup 2026-01-30 01:28:23 -06:00
J. Nick Koston
19e9ab253e cleanup 2026-01-30 01:24:48 -06:00
J. Nick Koston
e3a99f12e4 more edge cases 2026-01-30 01:22:32 -06:00
J. Nick Koston
d31a860bf2 fix, macos and linux disagree on ambig time 2026-01-30 01:18:16 -06:00
J. Nick Koston
cfea3472bd cleanups 2026-01-30 01:11:31 -06:00
J. Nick Koston
31859a3eb5 fix 2026-01-30 01:10:43 -06:00
J. Nick Koston
9f3e5f990f cleanups 2026-01-30 01:09:30 -06:00
J. Nick Koston
f317f58545 cleanups 2026-01-30 01:09:06 -06:00
J. Nick Koston
01c23eace3 cleanups 2026-01-30 01:06:46 -06:00
J. Nick Koston
9b8556c2b2 fix 2026-01-30 01:03:42 -06:00
J. Nick Koston
9628c213b5 make human readable 2026-01-30 01:01:21 -06:00
J. Nick Koston
07a71c412d make human readable 2026-01-30 01:00:07 -06:00
J. Nick Koston
0d736e4143 fix 2026-01-30 00:41:53 -06:00
J. Nick Koston
a93e3b6fa0 ambig time 2026-01-30 00:38:29 -06:00
J. Nick Koston
22ab20ba4c aioesphomeapi and esphome both always have M format, it was overkill 2026-01-30 00:36:17 -06:00
J. Nick Koston
6ee51b0159 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:25:42 -06:00
J. Nick Koston
e2b3186731 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:23:09 -06:00
J. Nick Koston
31aa58c45d bot review 2026-01-30 00:12:46 -06:00
J. Nick Koston
a757cb3c91 bot review 2026-01-30 00:03:28 -06:00
J. Nick Koston
91ad54d864 bot review 2026-01-30 00:03:13 -06:00
J. Nick Koston
3703755e03 more fixes 2026-01-29 23:59:39 -06:00
J. Nick Koston
c1d380dee4 more fixes 2026-01-29 23:58:07 -06:00
J. Nick Koston
b2120609b9 bot review 2026-01-29 23:54:14 -06:00
J. Nick Koston
9e6e8a7ecb bot review 2026-01-29 23:51:50 -06:00
J. Nick Koston
de06b36544 bot review 2026-01-29 23:50:37 -06:00
J. Nick Koston
695df9b979 bot review 2026-01-29 23:49:07 -06:00
J. Nick Koston
aa91cdd984 no setz 2026-01-29 23:47:28 -06:00
J. Nick Koston
284a9cdab6 must set TZ 2026-01-29 23:41:41 -06:00
J. Nick Koston
77ebfc8687 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:59 -06:00
J. Nick Koston
899f2bbac5 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:49 -06:00
J. Nick Koston
bb35e7b4b5 bad feedback from copilot 2026-01-29 23:31:09 -06:00
J. Nick Koston
64e4edd70f bad feedback from copilot 2026-01-29 23:30:33 -06:00
J. Nick Koston
300b7169ad cleanup 2026-01-29 23:29:10 -06:00
J. Nick Koston
1353dbc31e cleanup 2026-01-29 23:28:35 -06:00
J. Nick Koston
300eea034b handle trailing garbage 2026-01-29 23:26:53 -06:00
J. Nick Koston
90a06b5249 Merge branch 'dev' into posix_tz 2026-01-29 19:20:14 -10:00
J. Nick Koston
1b7b307d08 simplify 2026-01-29 22:57:17 -06:00
J. Nick Koston
a946aefbed more cover 2026-01-29 22:54:56 -06:00
J. Nick Koston
8708f96de4 less ram 2026-01-29 22:53:29 -06:00
J. Nick Koston
bd056b3b9e improve readability 2026-01-29 22:47:54 -06:00
J. Nick Koston
5d49c81e2d more cover 2026-01-29 22:42:33 -06:00
J. Nick Koston
bec7d6d223 tweak 2026-01-29 22:31:23 -06:00
J. Nick Koston
973105f2e5 tweak 2026-01-29 22:28:09 -06:00
J. Nick Koston
53fb876738 tests 2026-01-29 22:17:36 -06:00
J. Nick Koston
d2bc168f39 tweak 2026-01-29 22:07:34 -06:00
J. Nick Koston
34ec72ad49 tweak 2026-01-29 22:05:23 -06:00
J. Nick Koston
85c814b712 tweak 2026-01-29 22:02:46 -06:00
J. Nick Koston
fc951baebc tweak 2026-01-29 21:59:46 -06:00
J. Nick Koston
a1cdfe71de tweak 2026-01-29 21:54:40 -06:00
J. Nick Koston
c1971955a3 tweak 2026-01-29 21:53:43 -06:00
J. Nick Koston
e1df75fc9b tweak 2026-01-29 21:53:06 -06:00
J. Nick Koston
ea83330ab9 tweak 2026-01-29 21:52:24 -06:00
J. Nick Koston
4cdf0224ba tweak 2026-01-29 21:48:46 -06:00
J. Nick Koston
47f029b713 cover 2026-01-29 21:38:59 -06:00
J. Nick Koston
d45a20af83 tweak 2026-01-29 21:25:46 -06:00
J. Nick Koston
d37c37ef62 tweak 2026-01-29 21:19:00 -06:00
J. Nick Koston
aad3764806 posix_tz 2026-01-29 21:14:42 -06:00
20 changed files with 1154 additions and 966 deletions

View File

@@ -888,7 +888,7 @@ message GetTimeResponse {
option (no_delay) = true;
fixed32 epoch_seconds = 1;
string timezone = 2;
string timezone = 2 [deprecated = true]; // Use parsed_timezone instead. Remove before 2026.9.0.
ParsedTimezone parsed_timezone = 3;
}

View File

@@ -1126,10 +1126,12 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
#ifdef USE_TIME_TIMEZONE
if (!value.timezone.empty()) {
// 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.
// Only apply if the sender provided pre-parsed timezone data.
// Old clients (before 2026.3.0) only send the timezone string without the parsed struct,
// so all parsed_timezone fields default to zero — skip to keep the codegen-configured timezone.
// For actual UTC (all zeros), this also skips, which is harmless since UTC is the default.
// Eventually the timezone string will be removed and only the struct will be sent.
{
const auto &pt = value.parsed_timezone;
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
time::ParsedTimezone tz{};
@@ -1148,8 +1150,6 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
tz.dst_end.week = static_cast<uint8_t>(pt.dst_end.week);
tz.dst_end.day_of_week = static_cast<uint8_t>(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

View File

@@ -1089,10 +1089,6 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value
}
bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 2: {
this->timezone = StringRef(reinterpret_cast<const char *>(value.data()), value.size());
break;
}
case 3:
value.decode_to_message(this->parsed_timezone);
break;

View File

@@ -1195,12 +1195,11 @@ class ParsedTimezone final : public ProtoDecodableMessage {
class GetTimeResponse final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 37;
static constexpr uint8_t ESTIMATED_SIZE = 31;
static constexpr uint8_t ESTIMATED_SIZE = 22;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("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;

View File

@@ -1387,7 +1387,6 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const {
const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse"));
dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds);
dump_field(out, ESPHOME_PSTR("timezone"), this->timezone);
out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": ");
this->parsed_timezone.dump_to(out);
out.append("\n");

View File

@@ -377,11 +377,12 @@ async def setup_time_core_(time_var, config):
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)
_emit_parsed_timezone_fields(parsed)
cg.add(cg.RawExpression(f'setenv("TZ", "{timezone}", 1)'))
cg.add(cg.RawExpression("tzset()"))
# Pre-parse at codegen time, emit struct directly
parsed = parse_posix_tz_python(timezone)
_emit_parsed_timezone_fields(parsed)
for conf in config.get(CONF_ON_TIME, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)

View File

@@ -3,7 +3,6 @@
#ifdef USE_TIME_TIMEZONE
#include "posix_tz.h"
#include <cctype>
#include <cstdio>
namespace esphome::time {
@@ -18,17 +17,6 @@ const ParsedTimezone &get_global_tz() { return global_tz_; }
namespace internal {
// 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<unsigned char>(*p))) {
value = value * 10 + (*p - '0');
p++;
}
return value;
}
bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }
// Get days in year (avoids duplicate is_leap_year calls)
@@ -122,62 +110,6 @@ void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm)
out_tm->tm_isdst = 0;
}
bool skip_tz_name(const char *&p) {
if (*p == '<') {
// Angle-bracket quoted name: <+07>, <-03>, <AEST>
p++; // skip '<'
while (*p && *p != '>') {
p++;
}
if (*p == '>') {
p++; // skip '>'
return true;
}
return false; // Unterminated
}
// Standard name: 3+ letters
const char *start = p;
while (*p && std::isalpha(static_cast<unsigned char>(*p))) {
p++;
}
return (p - start) >= 3;
}
int32_t __attribute__((noinline)) parse_offset(const char *&p) {
int sign = 1;
if (*p == '-') {
sign = -1;
p++;
} else if (*p == '+') {
p++;
}
int hours = parse_uint(p);
int minutes = 0;
int seconds = 0;
if (*p == ':') {
p++;
minutes = parse_uint(p);
if (*p == ':') {
p++;
seconds = parse_uint(p);
}
}
return sign * (hours * 3600 + minutes * 60 + seconds);
}
// Helper to parse the optional /time suffix (reuses parse_offset logic)
static void parse_transition_time(const char *&p, DSTRule &rule) {
rule.time_seconds = 2 * 3600; // Default 02:00
if (*p == '/') {
p++;
rule.time_seconds = parse_offset(p);
}
}
void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) {
// J format: day 1-365, Feb 29 is NOT counted even in leap years
// So day 60 is always March 1
@@ -218,59 +150,6 @@ void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int yea
out_day = 31;
}
bool parse_dst_rule(const char *&p, DSTRule &rule) {
rule = {}; // Zero initialize
if (*p == 'M' || *p == 'm') {
// M format: Mm.w.d (month.week.day)
rule.type = DSTRuleType::MONTH_WEEK_DAY;
p++;
rule.month = parse_uint(p);
if (rule.month < 1 || rule.month > 12)
return false;
if (*p++ != '.')
return false;
rule.week = parse_uint(p);
if (rule.week < 1 || rule.week > 5)
return false;
if (*p++ != '.')
return false;
rule.day_of_week = parse_uint(p);
if (rule.day_of_week > 6)
return false;
} else if (*p == 'J' || *p == 'j') {
// J format: Jn (Julian day 1-365, not counting Feb 29)
rule.type = DSTRuleType::JULIAN_NO_LEAP;
p++;
rule.day = parse_uint(p);
if (rule.day < 1 || rule.day > 365)
return false;
} else if (std::isdigit(static_cast<unsigned char>(*p))) {
// Plain number format: n (day 0-365, counting Feb 29)
rule.type = DSTRuleType::DAY_OF_YEAR;
rule.day = parse_uint(p);
if (rule.day > 365)
return false;
} else {
return false;
}
// Parse optional /time suffix
parse_transition_time(p, rule);
return true;
}
// Calculate days from Jan 1 of given year to given month/day
static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) {
int days = day - 1;
@@ -366,83 +245,6 @@ 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;
}
const char *p = tz_string;
// Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false)
result.std_offset_seconds = 0;
result.dst_offset_seconds = 0;
result.dst_start = {};
result.dst_end = {};
// Skip standard timezone name
if (!internal::skip_tz_name(p)) {
return false;
}
// Parse standard offset (required)
if (!*p || (!std::isdigit(static_cast<unsigned char>(*p)) && *p != '+' && *p != '-')) {
return false;
}
result.std_offset_seconds = internal::parse_offset(p);
// Check for DST name
if (!*p) {
return true; // No DST
}
// If next char is comma, there's no DST name but there are rules (invalid)
if (*p == ',') {
return false;
}
// Check if there's something that looks like a DST name start
// (letter or angle bracket). If not, treat as trailing garbage and return success.
if (!std::isalpha(static_cast<unsigned char>(*p)) && *p != '<') {
return true; // No DST, trailing characters ignored
}
if (!internal::skip_tz_name(p)) {
return false; // Invalid DST name (started but malformed)
}
// Optional DST offset (default is std - 1 hour)
if (*p && *p != ',' && (std::isdigit(static_cast<unsigned char>(*p)) || *p == '+' || *p == '-')) {
result.dst_offset_seconds = internal::parse_offset(p);
} else {
result.dst_offset_seconds = result.std_offset_seconds - 3600;
}
// Parse DST rules (required when DST name is present)
if (*p != ',') {
// DST name without rules - treat as no DST since we can't determine transitions
return true;
}
p++;
if (!internal::parse_dst_rule(p, result.dst_start)) {
return false;
}
// Second rule is required per POSIX
if (*p != ',') {
return false;
}
p++;
// has_dst() now returns true since dst_start.type was set by parse_dst_rule
return internal::parse_dst_rule(p, result.dst_end);
}
// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display.
// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822).
void format_designation(int32_t posix_offset, char *buf, size_t buf_size) {

View File

@@ -39,28 +39,6 @@ struct ParsedTimezone {
/// Format a POSIX offset as "+HHMM"/"-HHMM" into buf (must be >= 6 bytes).
void format_designation(int32_t posix_offset, char *buf, size_t buf_size);
/// 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)
/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times)
/// - "<+07>-7" (angle-bracket notation for special names)
/// - "IST-5:30" (half-hour offsets)
/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day)
/// - "EST5EDT,60,300" (plain day number: day of year with leap day)
/// @param tz_string The POSIX TZ string to parse
/// @param result Output: the parsed timezone data
/// @return true if parsing succeeded, false on error
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result);
/// Convert a UTC epoch to local time using the parsed timezone.
/// This replaces libc's localtime() to avoid scanf dependency.
/// @param utc_epoch Unix timestamp in UTC
@@ -84,29 +62,9 @@ const ParsedTimezone &get_global_tz();
bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz);
// 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 {
/// Skip a timezone name (letters or <...> quoted format)
/// @param p Pointer to current position, updated on return
/// @return true if a valid name was found
bool skip_tz_name(const char *&p);
/// Parse an offset in format [-]hh[:mm[:ss]]
/// @param p Pointer to current position, updated on return
/// @return Offset in seconds
int32_t parse_offset(const char *&p);
/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time]
/// @param p Pointer to current position, updated on return
/// @param rule Output: the parsed rule
/// @return true if parsing succeeded
bool parse_dst_rule(const char *&p, DSTRule &rule);
/// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day
/// @param julian_day Day number 1-365
/// @param[out] month Output: month 1-12

View File

@@ -107,35 +107,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
this->time_sync_callback_.call();
}
#ifdef USE_TIME_TIMEZONE
void RealTimeClock::apply_timezone_(const char *tz) {
ParsedTimezone parsed{};
// Handle null or empty input - use UTC
if (tz == nullptr || *tz == '\0') {
// Skip if already UTC
if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) {
return;
}
set_global_tz(parsed);
return;
}
#ifdef USE_HOST
// On host platform, also set TZ environment variable for libc compatibility
setenv("TZ", tz, 1);
tzset();
#endif
// Parse the POSIX TZ string using our custom parser
if (!parse_posix_tz(tz, parsed)) {
ESP_LOGW(TAG, "Failed to parse timezone: %s", tz);
return;
}
// Set global timezone for all time conversions
set_global_tz(parsed);
}
#endif
} // namespace esphome::time

View File

@@ -22,30 +22,6 @@ class RealTimeClock : public PollingComponent {
public:
explicit RealTimeClock();
#ifdef USE_TIME_TIMEZONE
/// Set the time zone from a POSIX TZ string.
void set_timezone(const char *tz) { this->apply_timezone_(tz); }
/// Set the time zone from a character buffer with known length.
/// The buffer does not need to be null-terminated.
void set_timezone(const char *tz, size_t len) {
if (tz == nullptr) {
this->apply_timezone_(nullptr);
return;
}
// Stack buffer - TZ strings from tzdata are typically short (< 50 chars)
char buf[128];
if (len >= sizeof(buf))
len = sizeof(buf) - 1;
memcpy(buf, tz, len);
buf[len] = '\0';
this->apply_timezone_(buf);
}
/// Set the time zone from a std::string.
void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); }
#endif
/// Get the time in the currently defined timezone.
ESPTime now();
@@ -65,10 +41,6 @@ class RealTimeClock : public PollingComponent {
/// Report a unix epoch as current time.
void synchronize_epoch_(uint32_t epoch);
#ifdef USE_TIME_TIMEZONE
void apply_timezone_(const char *tz);
#endif
LazyCallbackManager<void()> time_sync_callback_;
};

View File

@@ -201,7 +201,7 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr,
static_cast<uint32_t>(InternalSchedulerID::DELAY_ACTION), this->delay_.value(),
[this]() { this->play_next_(); },
/* skip_cancel= */ this->num_running_ > 1);
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
} else {
// For delays with arguments, capture by value to preserve argument values
// Arguments must be copied because original references may be invalid after delay
@@ -209,7 +209,7 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL,
nullptr, static_cast<uint32_t>(InternalSchedulerID::DELAY_ACTION),
this->delay_.value(x...), std::move(f),
/* skip_cancel= */ this->num_running_ > 1);
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
}
}
float get_setup_priority() const override { return setup_priority::HARDWARE; }

View File

@@ -112,6 +112,36 @@ bool Component::cancel_interval(const char *name) { // NOLINT
return App.scheduler.cancel_interval(this, name);
}
void Component::set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor);
#pragma GCC diagnostic pop
}
void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor);
#pragma GCC diagnostic pop
}
bool Component::cancel_retry(const std::string &name) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return App.scheduler.cancel_retry(this, name);
#pragma GCC diagnostic pop
}
bool Component::cancel_retry(const char *name) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return App.scheduler.cancel_retry(this, name);
#pragma GCC diagnostic pop
}
void Component::set_timeout(const std::string &name, uint32_t timeout, std::function<void()> &&f) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
@@ -159,6 +189,21 @@ void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::fun
bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); }
void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
App.scheduler.set_retry(this, id, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor);
#pragma GCC diagnostic pop
}
bool Component::cancel_retry(uint32_t id) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return App.scheduler.cancel_retry(this, id);
#pragma GCC diagnostic pop
}
void Component::call_setup() { this->setup(); }
void Component::call_dump_config_() {
this->dump_config();
@@ -317,6 +362,13 @@ void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) { // N
void Component::set_interval(uint32_t interval, std::function<void()> &&f) { // NOLINT
App.scheduler.set_interval(this, static_cast<const char *>(nullptr), interval, std::move(f));
}
void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> &&f,
float backoff_increase_factor) { // NOLINT
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
App.scheduler.set_retry(this, "", initial_wait_time, max_attempts, std::move(f), backoff_increase_factor);
#pragma GCC diagnostic pop
}
bool Component::is_ready() const {
// Bitmask check: valid states are SETUP(1), LOOP(2), LOOP_DONE(4)
// (1 << state) & 0b10110 checks membership in one instruction

View File

@@ -89,6 +89,8 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };
inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms)
@@ -420,6 +422,41 @@ class Component {
bool cancel_interval(uint32_t id); // NOLINT
bool cancel_interval(InternalSchedulerID id); // NOLINT
/// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0.
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor = 1.0f); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor = 1.0f); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT
std::function<RetryResult(uint8_t)> &&f, float backoff_increase_factor = 1.0f); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> &&f, // NOLINT
float backoff_increase_factor = 1.0f); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(const std::string &name); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(const char *name); // NOLINT
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(uint32_t id); // NOLINT
/** Set a timeout function with a unique name.
*
* Similar to javascript's setTimeout(). Empty name means no cancelling possible.

View File

@@ -112,23 +112,55 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) {
return static_cast<uint32_t>((static_cast<uint64_t>(random_uint32()) * max_offset) >> 32);
}
// Check if a retry was already cancelled in items_ or to_add_
// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated
// Remove before 2026.8.0 along with all retry code
bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id) {
for (auto *container : {&this->items_, &this->to_add_}) {
for (auto *item : *container) {
if (item != nullptr && this->is_item_removed_locked_(item) &&
this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT,
/* match_retry= */ true, /* skip_removed= */ false)) {
return true;
}
}
}
return false;
}
// Common implementation for both timeout and interval
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
const char *static_name, uint32_t hash_or_id, uint32_t delay,
std::function<void()> &&func, bool skip_cancel) {
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
if (delay == SCHEDULER_DONT_RUN) {
// Still need to cancel existing timer if we have a name/id
if (!skip_cancel) {
LockGuard guard{this->lock_};
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true);
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false,
/* find_first= */ true);
}
return;
}
// Take lock early to protect scheduler_item_pool_ access
// Take lock early to protect scheduler_item_pool_ access and retry-cancelled check
LockGuard guard{this->lock_};
// For retries, check if there's a cancelled timeout first - before allocating an item.
// Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name
// Skip check for defer (delay=0) - deferred retries bypass the cancellation check
if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) &&
type == SchedulerItem::TIMEOUT &&
this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) {
#ifdef ESPHOME_DEBUG_SCHEDULER
SchedulerNameLog skip_name_log;
ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item",
skip_name_log.format(name_type, static_name, hash_or_id));
#endif
return;
}
// Create and populate the scheduler item
SchedulerItem *item = this->get_item_from_pool_locked_();
item->component = component;
@@ -143,6 +175,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
new (&item->callback) std::function<void()>(std::move(func));
// Reset remove flag - recycled items may have been cancelled (remove=true) in previous use
this->set_item_removed_(item, false);
item->is_retry = is_retry;
// Determine target container: defer_queue_ for deferred items, to_add_ for everything else.
// Using a pointer lets both paths share the cancel + push_back epilogue.
@@ -183,7 +216,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
// Common epilogue: atomic cancel-and-add (unless skip_cancel is true)
if (!skip_cancel) {
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true);
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false,
/* find_first= */ true);
}
target->push_back(item);
if (target == &this->to_add_) {
@@ -245,6 +279,125 @@ bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) {
return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL);
}
// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation.
// Remove before 2026.8.0 along with all retry code.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
struct RetryArgs {
// Ordered to minimize padding on 32-bit systems
std::function<RetryResult(uint8_t)> func;
Component *component;
Scheduler *scheduler;
// Union for name storage - only one is used based on name_type
union {
const char *static_name; // For STATIC_STRING
uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID
} name_;
uint32_t current_interval;
float backoff_increase_factor;
Scheduler::NameType name_type; // Discriminator for name_ union
uint8_t retry_countdown;
};
void retry_handler(const std::shared_ptr<RetryArgs> &args) {
RetryResult const retry_result = args->func(--args->retry_countdown);
if (retry_result == RetryResult::DONE || args->retry_countdown <= 0)
return;
// second execution of `func` happens after `initial_wait_time`
// args->name_ is owned by the shared_ptr<RetryArgs>
// which is captured in the lambda and outlives the SchedulerItem
const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr;
uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0;
args->scheduler->set_timer_common_(
args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id,
args->current_interval, [args]() { retry_handler(args); },
/* is_retry= */ true);
// backoff_increase_factor applied to third & later executions
args->current_interval *= args->backoff_increase_factor;
}
void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->cancel_retry_(component, name_type, static_name, hash_or_id);
if (initial_wait_time == SCHEDULER_DONT_RUN)
return;
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
{
SchedulerNameLog name_log;
ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)",
name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts,
backoff_increase_factor);
}
#endif
if (backoff_increase_factor < 0.0001) {
ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor,
(name_type == NameType::STATIC_STRING && static_name) ? static_name : "");
backoff_increase_factor = 1;
}
auto args = std::make_shared<RetryArgs>();
args->func = std::move(func);
args->component = component;
args->scheduler = this;
args->name_type = name_type;
if (name_type == NameType::STATIC_STRING) {
args->name_.static_name = static_name;
} else {
args->name_.hash_or_id = hash_or_id;
}
args->current_interval = initial_wait_time;
args->backoff_increase_factor = backoff_increase_factor;
args->retry_countdown = max_attempts;
// First execution of `func` immediately - use set_timer_common_ with is_retry=true
this->set_timer_common_(
component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); },
/* is_retry= */ true);
}
void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func),
backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id) {
return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT,
/* match_retry= */ true);
}
bool HOT Scheduler::cancel_retry(Component *component, const char *name) {
return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0);
}
void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time,
uint8_t max_attempts, std::function<RetryResult(uint8_t)> func,
float backoff_increase_factor) {
this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time,
max_attempts, std::move(func), backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) {
return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name));
}
void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts,
std::move(func), backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) {
return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id);
}
#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings
optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
// IMPORTANT: This method should only be called from the main thread (loop task).
// It performs cleanup and accesses items_[0] without holding a lock, which is only
@@ -576,11 +729,11 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
// Common implementation for cancel operations - handles locking
bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type) {
SchedulerItem::Type type, bool match_retry) {
LockGuard guard{this->lock_};
// Public cancel path uses default find_first=false to cancel ALL matches because
// DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key.
return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type);
return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry);
}
// Helper to cancel matching items - must be called with lock held.
@@ -590,7 +743,8 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const
// public cancel path where DelayAction parallel mode can create duplicates).
// name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id
bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id, SchedulerItem::Type type, bool find_first) {
uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry,
bool find_first) {
// Early return if static string name is invalid
if (name_type == NameType::STATIC_STRING && static_name == nullptr) {
return false;
@@ -602,7 +756,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type
// Mark items in defer queue as cancelled (they'll be skipped when processed)
if (type == SchedulerItem::TIMEOUT) {
total_cancelled += this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name,
hash_or_id, type, find_first);
hash_or_id, type, match_retry, find_first);
if (find_first && total_cancelled > 0)
return true;
}
@@ -615,7 +769,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type
// Only the main loop in call() should recycle items after execution completes.
if (!this->items_.empty()) {
size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name,
hash_or_id, type, find_first);
hash_or_id, type, match_retry, find_first);
total_cancelled += heap_cancelled;
this->to_remove_add_(heap_cancelled);
if (find_first && total_cancelled > 0)
@@ -624,7 +778,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type
// Cancel items in to_add_
total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name,
hash_or_id, type, find_first);
hash_or_id, type, match_retry, find_first);
return total_cancelled > 0;
}

View File

@@ -16,8 +16,14 @@
namespace esphome {
class Component;
struct RetryArgs;
// Forward declaration of retry_handler - needs to be non-static for friend declaration
void retry_handler(const std::shared_ptr<RetryArgs> &args);
class Scheduler {
// Allow retry_handler to access protected members for internal retry mechanism
friend void ::esphome::retry_handler(const std::shared_ptr<RetryArgs> &args);
// Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays.
// This is needed to fix issue #10264 where parallel scripts with delays interfere with each other.
// We use friend instead of a public API because skip_cancel is dangerous - it can cause delays
@@ -85,6 +91,32 @@ class Scheduler {
SchedulerItem::INTERVAL);
}
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(Component *component, const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor = 1.0f);
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor = 1.0f);
// Remove before 2026.8.0
ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.",
"2026.2.0")
void set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor = 1.0f);
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(Component *component, const std::string &name);
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(Component *component, const char *name);
// Remove before 2026.8.0
ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0")
bool cancel_retry(Component *component, uint32_t id);
/// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover)
uint64_t millis_64() { return esphome::millis_64(); }
@@ -149,17 +181,19 @@ class Scheduler {
// std::atomic<uint8_t> inlines correctly on all platforms.
std::atomic<uint8_t> remove{0};
// Bit-packed fields (3 bits used, 5 bits padding in 1 byte)
// Bit-packed fields (4 bits used, 4 bits padding in 1 byte)
enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
NameType name_type_ : 2; // Discriminator for name_ union (03, see NameType enum)
// 5 bits padding
bool is_retry : 1; // True if this is a retry timeout
// 4 bits padding
#else
// Single-threaded or multi-threaded without atomics: can pack all fields together
// Bit-packed fields (4 bits used, 4 bits padding in 1 byte)
// Bit-packed fields (5 bits used, 3 bits padding in 1 byte)
enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1;
bool remove : 1;
NameType name_type_ : 2; // Discriminator for name_ union (03, see NameType enum)
// 4 bits padding
bool is_retry : 1; // True if this is a retry timeout
// 3 bits padding
#endif
// Constructor
@@ -171,11 +205,13 @@ class Scheduler {
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
// remove is initialized in the member declaration
type(TIMEOUT),
name_type_(NameType::STATIC_STRING) {
name_type_(NameType::STATIC_STRING),
is_retry(false) {
#else
type(TIMEOUT),
remove(false),
name_type_(NameType::STATIC_STRING) {
name_type_(NameType::STATIC_STRING),
is_retry(false) {
#endif
name_.static_name = nullptr;
}
@@ -233,7 +269,19 @@ class Scheduler {
// Common implementation for both timeout and interval
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name,
uint32_t hash_or_id, uint32_t delay, std::function<void()> &&func, bool skip_cancel = false);
uint32_t hash_or_id, uint32_t delay, std::function<void()> &&func, bool is_retry = false,
bool skip_cancel = false);
// Common implementation for retry - Remove before 2026.8.0
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
void set_retry_common_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> func,
float backoff_increase_factor);
#pragma GCC diagnostic pop
// Common implementation for cancel_retry
bool cancel_retry_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id);
// Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now.
// On platforms with native 64-bit time, ignores now and uses millis_64() directly.
@@ -279,11 +327,11 @@ class Scheduler {
// mode where skip_cancel=true allows multiple items with the same key).
// name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id
bool cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type, bool find_first = false);
SchedulerItem::Type type, bool match_retry = false, bool find_first = false);
// Common implementation for cancel operations - handles locking
bool cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type);
SchedulerItem::Type type, bool match_retry = false);
// Helper to check if two static string names match
inline bool HOT names_match_static_(const char *name1, const char *name2) const {
@@ -299,13 +347,14 @@ class Scheduler {
// IMPORTANT: Must be called with scheduler lock held
inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type,
const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type,
bool skip_removed = true) const {
bool match_retry, bool skip_removed = true) const {
// THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded
// platforms, items can be nulled in defer_queue_ during processing.
// Fixes: https://github.com/esphome/esphome/issues/11940
if (item == nullptr)
return false;
if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item))) {
if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) ||
(match_retry && !item->is_retry)) {
return false;
}
// Name type must match
@@ -341,6 +390,13 @@ class Scheduler {
// IMPORTANT: Must not be inlined - called only for intervals, keeping it out of the hot path saves flash.
uint32_t __attribute__((noinline)) calculate_interval_offset_(uint32_t delay);
// Helper to check if a retry was already cancelled - extracted to reduce code size of set_timer_common_
// Remove before 2026.8.0 along with all retry code.
// IMPORTANT: Must not be inlined - retry path is cold and deprecated.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
bool __attribute__((noinline))
is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id);
#ifdef ESPHOME_DEBUG_SCHEDULER
// Helper for debug logging in set_timer_common_ - extracted to reduce code size
void debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id,
@@ -442,11 +498,11 @@ class Scheduler {
__attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector<SchedulerItem *> &container,
Component *component, NameType name_type,
const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type,
SchedulerItem::Type type, bool match_retry,
bool find_first = false) {
size_t count = 0;
for (auto *item : container) {
if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type)) {
if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) {
this->set_item_removed_(item, true);
if (find_first)
return 1;

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@ globals:
- id: interval_counter
type: int
initial_value: '0'
- id: retry_counter
type: int
initial_value: '0'
- id: defer_counter
type: int
initial_value: '0'
@@ -115,7 +118,29 @@ script:
id(timeout_counter) += 1;
});
// Test 10: defer with numeric ID (Component method)
// Test 10: set_retry with numeric ID
App.scheduler.set_retry(component1, 6001U, 50, 3,
[](uint8_t retry_countdown) {
id(retry_counter)++;
ESP_LOGI("test", "Numeric retry 6001 attempt %d (countdown=%d)",
id(retry_counter), retry_countdown);
if (id(retry_counter) >= 2) {
ESP_LOGI("test", "Numeric retry 6001 done");
return RetryResult::DONE;
}
return RetryResult::RETRY;
});
// Test 11: cancel_retry with numeric ID
App.scheduler.set_retry(component1, 6002U, 100, 5,
[](uint8_t retry_countdown) {
ESP_LOGE("test", "ERROR: Numeric retry 6002 should have been cancelled");
return RetryResult::RETRY;
});
App.scheduler.cancel_retry(component1, 6002U);
ESP_LOGI("test", "Cancelled numeric retry 6002");
// Test 12: defer with numeric ID (Component method)
class TestDeferComponent : public Component {
public:
void test_defer_methods() {
@@ -136,7 +161,7 @@ script:
static TestDeferComponent test_defer_component;
test_defer_component.test_defer_methods();
// Test 11: cancel_defer with numeric ID (Component method)
// Test 13: cancel_defer with numeric ID (Component method)
class TestCancelDeferComponent : public Component {
public:
void test_cancel_defer() {
@@ -156,8 +181,8 @@ script:
- id: report_results
then:
- lambda: |-
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(defer_counter));
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
sensor:
- platform: template

View File

@@ -0,0 +1,287 @@
esphome:
debug_scheduler: true # Enable scheduler leak detection
name: scheduler-retry-test
on_boot:
priority: -100
then:
- logger.log: "Starting scheduler retry tests"
# Run all tests sequentially with delays
- script.execute: run_all_tests
host:
api:
logger:
level: VERY_VERBOSE
globals:
- id: simple_retry_counter
type: int
initial_value: '0'
- id: backoff_retry_counter
type: int
initial_value: '0'
- id: backoff_last_attempt_time
type: uint32_t
initial_value: '0'
- id: immediate_done_counter
type: int
initial_value: '0'
- id: cancel_retry_counter
type: int
initial_value: '0'
- id: empty_name_retry_counter
type: int
initial_value: '0'
- id: script_retry_counter
type: int
initial_value: '0'
- id: multiple_same_name_counter
type: int
initial_value: '0'
- id: const_char_retry_counter
type: int
initial_value: '0'
- id: static_char_retry_counter
type: int
initial_value: '0'
# Using different component types for each test to ensure isolation
sensor:
- platform: template
name: Simple Retry Test Sensor
id: simple_retry_sensor
lambda: return 1.0;
update_interval: never
- platform: template
name: Backoff Retry Test Sensor
id: backoff_retry_sensor
lambda: return 2.0;
update_interval: never
- platform: template
name: Immediate Done Test Sensor
id: immediate_done_sensor
lambda: return 3.0;
update_interval: never
binary_sensor:
- platform: template
name: Cancel Retry Test Binary Sensor
id: cancel_retry_binary_sensor
lambda: return false;
- platform: template
name: Empty Name Test Binary Sensor
id: empty_name_binary_sensor
lambda: return true;
switch:
- platform: template
name: Script Retry Test Switch
id: script_retry_switch
optimistic: true
- platform: template
name: Multiple Same Name Test Switch
id: multiple_same_name_switch
optimistic: true
script:
- id: run_all_tests
then:
# Test 1: Simple retry
- logger.log: "=== Test 1: Simple retry ==="
- lambda: |-
auto *component = id(simple_retry_sensor);
App.scheduler.set_retry(component, "simple_retry", 50, 3,
[](uint8_t retry_countdown) {
id(simple_retry_counter)++;
ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)",
id(simple_retry_counter), retry_countdown);
if (id(simple_retry_counter) >= 2) {
ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter));
return RetryResult::DONE;
}
return RetryResult::RETRY;
});
# Test 2: Backoff retry
- logger.log: "=== Test 2: Retry with backoff ==="
- lambda: |-
auto *component = id(backoff_retry_sensor);
App.scheduler.set_retry(component, "backoff_retry", 50, 4,
[](uint8_t retry_countdown) {
id(backoff_retry_counter)++;
uint32_t now = millis();
uint32_t interval = 0;
// Only calculate interval after first attempt
if (id(backoff_retry_counter) > 1) {
interval = now - id(backoff_last_attempt_time);
}
id(backoff_last_attempt_time) = now;
ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)",
id(backoff_retry_counter), retry_countdown, interval);
if (id(backoff_retry_counter) == 1) {
ESP_LOGI("test", "First call was immediate");
} else if (id(backoff_retry_counter) == 2) {
ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval);
} else if (id(backoff_retry_counter) == 3) {
ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval);
} else if (id(backoff_retry_counter) == 4) {
ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval);
ESP_LOGI("test", "Backoff retry completed");
return RetryResult::DONE;
}
return RetryResult::RETRY;
}, 2.0f);
# Test 3: Immediate done
- logger.log: "=== Test 3: Immediate done ==="
- lambda: |-
auto *component = id(immediate_done_sensor);
App.scheduler.set_retry(component, "immediate_done", 50, 5,
[](uint8_t retry_countdown) {
id(immediate_done_counter)++;
ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown);
return RetryResult::DONE;
});
# Test 4: Cancel retry
- logger.log: "=== Test 4: Cancel retry ==="
- lambda: |-
auto *component = id(cancel_retry_binary_sensor);
App.scheduler.set_retry(component, "cancel_test", 30, 10,
[](uint8_t retry_countdown) {
id(cancel_retry_counter)++;
ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter));
return RetryResult::RETRY;
});
// Cancel it after 100ms
App.scheduler.set_timeout(component, "cancel_timer", 100, []() {
bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test");
ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false");
ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter));
});
# Test 5: Empty name retry
- logger.log: "=== Test 5: Empty name retry ==="
- lambda: |-
auto *component = id(empty_name_binary_sensor);
App.scheduler.set_retry(component, "", 100, 5,
[](uint8_t retry_countdown) {
id(empty_name_retry_counter)++;
ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter));
return RetryResult::RETRY;
});
// Try to cancel after 150ms
App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() {
bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), "");
ESP_LOGI("test", "Empty name retry cancel result: %s",
cancelled ? "true" : "false");
ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter));
});
# Test 6: Component method
- logger.log: "=== Test 6: Component::set_retry method ==="
- lambda: |-
class TestRetryComponent : public Component {
public:
void test_retry() {
this->set_retry(50, 3,
[](uint8_t retry_countdown) {
id(script_retry_counter)++;
ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter));
if (id(script_retry_counter) >= 2) {
return RetryResult::DONE;
}
return RetryResult::RETRY;
}, 1.5f);
}
};
static TestRetryComponent test_component;
test_component.test_retry();
# Test 7: Multiple same name
- logger.log: "=== Test 7: Multiple retries with same name ==="
- lambda: |-
auto *component = id(multiple_same_name_switch);
// Set first retry
App.scheduler.set_retry(component, "duplicate_retry", 100, 5,
[](uint8_t retry_countdown) {
id(multiple_same_name_counter) += 1;
ESP_LOGI("test", "First duplicate retry - should not run");
return RetryResult::RETRY;
});
// Set second retry with same name (should cancel first)
App.scheduler.set_retry(component, "duplicate_retry", 50, 3,
[](uint8_t retry_countdown) {
id(multiple_same_name_counter) += 10;
ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)",
id(multiple_same_name_counter));
if (id(multiple_same_name_counter) >= 20) {
return RetryResult::DONE;
}
return RetryResult::RETRY;
});
# Test 8: Const char* overloads
- logger.log: "=== Test 8: Const char* overloads ==="
- lambda: |-
auto *component = id(simple_retry_sensor);
// Test 8a: Direct string literal
App.scheduler.set_retry(component, "const_char_test", 30, 2,
[](uint8_t retry_countdown) {
id(const_char_retry_counter)++;
ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter));
return RetryResult::DONE;
});
# Test 9: Static const char* variable
- logger.log: "=== Test 9: Static const char* ==="
- lambda: |-
auto *component = id(backoff_retry_sensor);
static const char* STATIC_NAME = "static_retry_test";
App.scheduler.set_retry(component, STATIC_NAME, 20, 1,
[](uint8_t retry_countdown) {
id(static_char_retry_counter)++;
ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter));
return RetryResult::DONE;
});
// Cancel with same static const char*
App.scheduler.set_timeout(component, "static_cancel", 10, []() {
static const char* STATIC_NAME = "static_retry_test";
bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME);
ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false");
});
# Wait for all tests to complete before reporting
- delay: 500ms
# Final report
- logger.log: "=== Retry Test Results ==="
- lambda: |-
ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter));
ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter));
ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter));
ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter));
ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter));
ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter));
ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter));
ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter));
ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter));
ESP_LOGI("test", "All retry tests completed");

View File

@@ -18,6 +18,7 @@ async def test_scheduler_numeric_id_test(
# Track counts
timeout_count = 0
interval_count = 0
retry_count = 0
defer_count = 0
# Events for each test completion
@@ -31,6 +32,8 @@ async def test_scheduler_numeric_id_test(
component_interval_fired = asyncio.Event()
zero_id_timeout_fired = asyncio.Event()
max_id_timeout_fired = asyncio.Event()
numeric_retry_done = asyncio.Event()
numeric_retry_cancelled = asyncio.Event()
numeric_defer_7001_fired = asyncio.Event()
numeric_defer_7002_fired = asyncio.Event()
numeric_defer_cancelled = asyncio.Event()
@@ -38,10 +41,11 @@ async def test_scheduler_numeric_id_test(
# Track interval counts
numeric_interval_count = 0
numeric_retry_count = 0
def on_log_line(line: str) -> None:
nonlocal timeout_count, interval_count, defer_count
nonlocal numeric_interval_count
nonlocal timeout_count, interval_count, retry_count, defer_count
nonlocal numeric_interval_count, numeric_retry_count
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
@@ -93,6 +97,18 @@ async def test_scheduler_numeric_id_test(
max_id_timeout_fired.set()
timeout_count += 1
# Check for numeric retry tests
elif "Numeric retry 6001 attempt" in clean_line:
match = re.search(r"attempt (\d+)", clean_line)
if match:
numeric_retry_count = int(match.group(1))
elif "Numeric retry 6001 done" in clean_line:
numeric_retry_done.set()
elif "Cancelled numeric retry 6002" in clean_line:
numeric_retry_cancelled.set()
# Check for numeric defer tests
elif "Component numeric defer 7001 fired" in clean_line:
numeric_defer_7001_fired.set()
@@ -106,13 +122,14 @@ async def test_scheduler_numeric_id_test(
# Check for final results
elif "Final results" in clean_line:
match = re.search(
r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)",
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
clean_line,
)
if match:
timeout_count = int(match.group(1))
interval_count = int(match.group(2))
defer_count = int(match.group(3))
retry_count = int(match.group(3))
defer_count = int(match.group(4))
final_results_logged.set()
async with (
@@ -183,6 +200,23 @@ async def test_scheduler_numeric_id_test(
except TimeoutError:
pytest.fail("Max ID timeout did not fire within 0.5 seconds")
# Wait for numeric retry tests
try:
await asyncio.wait_for(numeric_retry_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Numeric retry 6001 did not complete. Count: {numeric_retry_count}"
)
assert numeric_retry_count >= 2, (
f"Expected at least 2 numeric retry attempts, got {numeric_retry_count}"
)
# Verify numeric retry was cancelled
assert numeric_retry_cancelled.is_set(), (
"Numeric retry 6002 should have been cancelled"
)
# Wait for numeric defer tests
try:
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
@@ -211,4 +245,7 @@ async def test_scheduler_numeric_id_test(
assert interval_count >= 3, (
f"Expected at least 3 interval fires, got {interval_count}"
)
assert retry_count >= 2, (
f"Expected at least 2 retry attempts, got {retry_count}"
)
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"

View File

@@ -0,0 +1,279 @@
"""Test scheduler retry functionality."""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_retry_test(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that scheduler retry functionality works correctly."""
# Track test progress
simple_retry_done = asyncio.Event()
backoff_retry_done = asyncio.Event()
immediate_done_done = asyncio.Event()
cancel_retry_done = asyncio.Event()
empty_name_retry_done = asyncio.Event()
component_retry_done = asyncio.Event()
multiple_name_done = asyncio.Event()
const_char_done = asyncio.Event()
static_char_done = asyncio.Event()
test_complete = asyncio.Event()
# Track retry counts
simple_retry_count = 0
backoff_retry_count = 0
immediate_done_count = 0
cancel_retry_count = 0
empty_name_retry_count = 0
component_retry_count = 0
multiple_name_count = 0
const_char_retry_count = 0
static_char_retry_count = 0
# Track specific test results
cancel_result = None
empty_cancel_result = None
backoff_intervals = []
def on_log_line(line: str) -> None:
nonlocal simple_retry_count, backoff_retry_count, immediate_done_count
nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count
nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count
nonlocal cancel_result, empty_cancel_result
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Simple retry test
if "Simple retry attempt" in clean_line:
if match := re.search(r"Simple retry attempt (\d+)", clean_line):
simple_retry_count = int(match.group(1))
elif "Simple retry succeeded on attempt" in clean_line:
simple_retry_done.set()
# Backoff retry test
elif "Backoff retry attempt" in clean_line:
if match := re.search(
r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line
):
backoff_retry_count = int(match.group(1))
interval = int(match.group(2))
if backoff_retry_count > 1: # Skip first (immediate) call
backoff_intervals.append(interval)
elif "Backoff retry completed" in clean_line:
backoff_retry_done.set()
# Immediate done test
elif "Immediate done retry called" in clean_line:
immediate_done_count += 1
immediate_done_done.set()
# Cancel retry test
elif "Cancel test retry attempt" in clean_line:
cancel_retry_count += 1
elif "Retry cancellation result:" in clean_line:
cancel_result = "true" in clean_line
cancel_retry_done.set()
# Empty name retry test
elif "Empty name retry attempt" in clean_line:
if match := re.search(r"Empty name retry attempt (\d+)", clean_line):
empty_name_retry_count = int(match.group(1))
elif "Empty name retry cancel result:" in clean_line:
empty_cancel_result = "true" in clean_line
elif "Empty name retry ran" in clean_line:
empty_name_retry_done.set()
# Component retry test
elif "Component retry attempt" in clean_line:
if match := re.search(r"Component retry attempt (\d+)", clean_line):
component_retry_count = int(match.group(1))
if component_retry_count >= 2:
component_retry_done.set()
# Multiple same name test
elif "Second duplicate retry attempt" in clean_line:
if match := re.search(r"counter=(\d+)", clean_line):
multiple_name_count = int(match.group(1))
if multiple_name_count >= 20:
multiple_name_done.set()
# Const char retry test
elif "Const char retry" in clean_line:
if match := re.search(r"Const char retry (\d+)", clean_line):
const_char_retry_count = int(match.group(1))
const_char_done.set()
# Static const char retry test
elif "Static const char retry" in clean_line:
if match := re.search(r"Static const char retry (\d+)", clean_line):
static_char_retry_count = int(match.group(1))
static_char_done.set()
elif "Static cancel result:" in clean_line:
# This is part of test 9, but we don't track it separately
pass
# Test completion
elif "All retry tests completed" in clean_line:
test_complete.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-retry-test"
# Wait for simple retry test
try:
await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Simple retry test did not complete. Count: {simple_retry_count}"
)
assert simple_retry_count == 2, (
f"Expected 2 simple retry attempts, got {simple_retry_count}"
)
# Wait for backoff retry test
try:
await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0)
except TimeoutError:
pytest.fail(
f"Backoff retry test did not complete. Count: {backoff_retry_count}"
)
assert backoff_retry_count == 4, (
f"Expected 4 backoff retry attempts, got {backoff_retry_count}"
)
# Verify backoff intervals (allowing for timing variations)
assert len(backoff_intervals) >= 2, (
f"Expected at least 2 intervals, got {len(backoff_intervals)}"
)
if len(backoff_intervals) >= 3:
# First interval should be ~50ms (very wide tolerance for heavy system load)
assert 20 <= backoff_intervals[0] <= 150, (
f"First interval {backoff_intervals[0]}ms not ~50ms"
)
# Second interval should be ~100ms (50ms * 2.0)
assert 50 <= backoff_intervals[1] <= 250, (
f"Second interval {backoff_intervals[1]}ms not ~100ms"
)
# Third interval should be ~200ms (100ms * 2.0)
assert 100 <= backoff_intervals[2] <= 500, (
f"Third interval {backoff_intervals[2]}ms not ~200ms"
)
# Wait for immediate done test
try:
await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0)
except TimeoutError:
pytest.fail(
f"Immediate done test did not complete. Count: {immediate_done_count}"
)
assert immediate_done_count == 1, (
f"Expected 1 immediate done call, got {immediate_done_count}"
)
# Wait for cancel retry test
try:
await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0)
except TimeoutError:
pytest.fail(
f"Cancel retry test did not complete. Count: {cancel_retry_count}"
)
assert cancel_result is True, "Retry cancellation should have succeeded"
assert 2 <= cancel_retry_count <= 5, (
f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}"
)
# Wait for empty name retry test
try:
await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Empty name retry test did not complete. Count: {empty_name_retry_count}"
)
# Empty name retry should run at least once before being cancelled
assert 1 <= empty_name_retry_count <= 3, (
f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}"
)
assert empty_cancel_result is True, (
"Empty name retry cancel should have succeeded"
)
# Wait for component retry test
try:
await asyncio.wait_for(component_retry_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Component retry test did not complete. Count: {component_retry_count}"
)
assert component_retry_count >= 2, (
f"Expected at least 2 component retry attempts, got {component_retry_count}"
)
# Wait for multiple same name test
try:
await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Multiple same name test did not complete. Count: {multiple_name_count}"
)
# Should be 20+ (only second retry should run)
assert multiple_name_count >= 20, (
f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}"
)
# Wait for const char retry test
try:
await asyncio.wait_for(const_char_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Const char retry test did not complete. Count: {const_char_retry_count}"
)
assert const_char_retry_count == 1, (
f"Expected 1 const char retry call, got {const_char_retry_count}"
)
# Wait for static char retry test
try:
await asyncio.wait_for(static_char_done.wait(), timeout=1.0)
except TimeoutError:
pytest.fail(
f"Static char retry test did not complete. Count: {static_char_retry_count}"
)
assert static_char_retry_count == 1, (
f"Expected 1 static char retry call, got {static_char_retry_count}"
)
# Wait for test completion
try:
await asyncio.wait_for(test_complete.wait(), timeout=1.0)
except TimeoutError:
pytest.fail("Test did not complete within timeout")