diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index f388267abd2..c25248e4574 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -34,25 +34,43 @@ bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year // Get days in year (avoids duplicate is_leap_year calls) static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; } -// Convert days since epoch to year, updating days to remainder -static int __attribute__((noinline)) days_to_year(int64_t &days) { - int year = 1970; - int diy; - while (days >= (diy = days_in_year(year)) && year < 2200) { - days -= diy; +// Count leap years in [1, year] (i.e. up to and including year) +static constexpr int count_leap_years_up_to(int year) { return year / 4 - year / 100 + year / 400; } + +constexpr int EPOCH_YEAR = 1970; +constexpr int LEAP_YEARS_BEFORE_EPOCH = count_leap_years_up_to(EPOCH_YEAR - 1); +constexpr int DAYS_PER_YEAR = 365; +constexpr int SECONDS_PER_DAY = 86400; + +// Days from epoch (Jan 1 1970) to Jan 1 of given year — O(1) +static inline int64_t days_to_year_start(int year) { + return static_cast(DAYS_PER_YEAR) * (year - EPOCH_YEAR) + + (count_leap_years_up_to(year - 1) - LEAP_YEARS_BEFORE_EPOCH); +} + +// Convert days since epoch to year, updating days to day-of-year remainder. +// The initial estimate from days/365 can overshoot by multiple years for +// far-future dates (e.g., year 5000+) due to accumulated leap days, +// so we use loops rather than single-step correction. +static int days_to_year(int64_t &days) { + int year = static_cast(EPOCH_YEAR + days / DAYS_PER_YEAR); + int64_t year_start = days_to_year_start(year); + while (days < year_start) { + year--; + year_start = days_to_year_start(year); + } + while (days >= year_start + days_in_year(year)) { + year_start += days_in_year(year); year++; } - while (days < 0 && year > 1900) { - year--; - days += days_in_year(year); - } + days -= year_start; return year; } -// Extract just the year from a UTC epoch +// Extract just the year from a UTC epoch — O(1) static int epoch_to_year(time_t epoch) { - int64_t days = epoch / 86400; - if (epoch < 0 && epoch % 86400 != 0) + int64_t days = epoch / SECONDS_PER_DAY; + if (epoch < 0 && epoch % SECONDS_PER_DAY != 0) days--; return days_to_year(days); } @@ -87,11 +105,11 @@ int __attribute__((noinline)) day_of_week(int year, int month, int day) { void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { // Days since epoch - int64_t days = epoch / 86400; - int32_t remaining_secs = epoch % 86400; + int64_t days = epoch / SECONDS_PER_DAY; + int32_t remaining_secs = epoch % SECONDS_PER_DAY; if (remaining_secs < 0) { days--; - remaining_secs += 86400; + remaining_secs += SECONDS_PER_DAY; } out_tm->tm_sec = remaining_secs % 60; @@ -280,17 +298,6 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i return days; } -// Calculate days from epoch to Jan 1 of given year (for DST transition calculations) -// Only supports years >= 1970. Timezone is either compiled in from YAML or set by -// Home Assistant, so pre-1970 dates are not a concern. -static int64_t __attribute__((noinline)) days_to_year_start(int year) { - int64_t days = 0; - for (int y = 1970; y < year; y++) { - days += days_in_year(y); - } - return days; -} - time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { int month, day; @@ -339,7 +346,7 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day); // Convert to epoch and add transition time and base offset - return days * 86400 + rule.time_seconds + base_offset_seconds; + return days * SECONDS_PER_DAY + rule.time_seconds + base_offset_seconds; } } // namespace internal diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index b7cf2a4afad..440eea608d9 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -758,6 +758,115 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { EXPECT_EQ(local.tm_isdst, 1); } +// ============================================================================ +// Leap year edge cases for closed-form year arithmetic +// ============================================================================ + +TEST(PosixTzParser, EpochToLocalLeapYear2000) { + // 2000 is a leap year (divisible by 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + // Feb 29, 2000 12:00:00 UTC + time_t epoch = make_utc(2000, 2, 29, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 100); // 2000 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 12); +} + +TEST(PosixTzParser, EpochToLocalNonLeapYear2100) { + // 2100 is NOT a leap year (divisible by 100 but not 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + // Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29 + time_t epoch = make_utc(2100, 3, 1); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 2); // March + EXPECT_EQ(local.tm_mday, 1); + + // Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29) + epoch = make_utc(2100, 2, 28, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 28); +} + +TEST(PosixTzParser, EpochToLocalLeapYear2400) { + // 2400 is a leap year (divisible by 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + time_t epoch = make_utc(2400, 2, 29, 6); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 500); // 2400 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 6); +} + +TEST(PosixTzParser, EpochToLocalNewYearBoundaries) { + // Test year boundary — last second of 2099 and first second of 2100 + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + struct tm local; + + // Dec 31, 2099 23:59:59 UTC + time_t epoch = make_utc(2099, 12, 31, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 199); // 2099 + EXPECT_EQ(local.tm_mon, 11); // December + EXPECT_EQ(local.tm_mday, 31); + + // Jan 1, 2100 00:00:00 UTC + epoch = make_utc(2100, 1, 1); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 0); // January + EXPECT_EQ(local.tm_mday, 1); +} + +TEST(PosixTzParser, EpochToLocalDstAcrossCenturyBoundary) { + // DST transition in year 2100 (non-leap) with US Eastern rules + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz)); + + // July 4, 2100 16:00 UTC = 12:00 EDT + time_t epoch = make_utc(2100, 7, 4, 16); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 12); + EXPECT_EQ(local.tm_isdst, 1); + + // Jan 15, 2100 10:00 UTC = 05:00 EST + epoch = make_utc(2100, 1, 15, 10); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 5); + EXPECT_EQ(local.tm_isdst, 0); +} + +TEST(PosixTzParser, EpochToLocalFarFutureYear5000) { + // Year 5000 — days/365 estimate overshoots by ~2 years due to leap days, + // requiring multiple correction steps in days_to_year. + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + time_t epoch = make_utc(5000, 6, 15, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 3100); // 5000 + EXPECT_EQ(local.tm_mon, 5); // June + EXPECT_EQ(local.tm_mday, 15); + EXPECT_EQ(local.tm_hour, 12); +} + // ============================================================================ // Verification against libc // ============================================================================