diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index f878af5118..7a122b474a 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -245,6 +245,80 @@ bool parse_dst_rule(const char *&p, DSTRule &rule) { return true; } +time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { + int month, day; + + switch (rule.type) { + case DSTRuleType::MONTH_WEEK_DAY: { + // Find the nth occurrence of day_of_week in the given month + int first_day_of_month = day_of_week(year, rule.month, 1); + + // Days until first occurrence of target day + int days_until_first = (rule.day_of_week - first_day_of_month + 7) % 7; + int first_occurrence = 1 + days_until_first; + + if (rule.week == 5) { + // "Last" occurrence - find the last one in the month + int days_in_m = days_in_month(year, rule.month); + day = first_occurrence; + while (day + 7 <= days_in_m) { + day += 7; + } + } else { + // nth occurrence + day = first_occurrence + (rule.week - 1) * 7; + } + month = rule.month; + break; + } + + case DSTRuleType::JULIAN_NO_LEAP: + // J format: day 1-365, Feb 29 not counted + julian_to_month_day(rule.day, month, day); + break; + + case DSTRuleType::DAY_OF_YEAR: + // Plain format: day 0-365, Feb 29 counted + day_of_year_to_month_day(rule.day, year, month, day); + break; + } + + // Calculate days from epoch to this date + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += is_leap_year(y) ? 366 : 365; + } + for (int m = 1; m < month; m++) { + days += days_in_month(year, m); + } + days += day - 1; + + // Convert to epoch and add transition time and base offset + return days * 86400 + rule.time_seconds + base_offset_seconds; +} + +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { + if (!tz.has_dst) { + return false; + } + + int year = epoch_to_year(utc_epoch); + + // Calculate DST start and end for this year + // DST start transition happens in standard time + time_t dst_start = calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds); + // DST end transition happens in daylight time + time_t dst_end = calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds); + + if (dst_start < dst_end) { + // Northern hemisphere: DST is between start and end + return (utc_epoch >= dst_start && utc_epoch < dst_end); + } else { + // Southern hemisphere: DST is outside the range (wraps around year) + return (utc_epoch >= dst_start || utc_epoch < dst_end); + } +} + } // namespace internal bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { @@ -314,87 +388,13 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return true; } -time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { - int month, day; - - switch (rule.type) { - case DSTRuleType::MONTH_WEEK_DAY: { - // Find the nth occurrence of day_of_week in the given month - int first_day_of_month = internal::day_of_week(year, rule.month, 1); - - // Days until first occurrence of target day - int days_until_first = (rule.day_of_week - first_day_of_month + 7) % 7; - int first_occurrence = 1 + days_until_first; - - if (rule.week == 5) { - // "Last" occurrence - find the last one in the month - int days_in_m = internal::days_in_month(year, rule.month); - day = first_occurrence; - while (day + 7 <= days_in_m) { - day += 7; - } - } else { - // nth occurrence - day = first_occurrence + (rule.week - 1) * 7; - } - month = rule.month; - break; - } - - case DSTRuleType::JULIAN_NO_LEAP: - // J format: day 1-365, Feb 29 not counted - internal::julian_to_month_day(rule.day, month, day); - break; - - case DSTRuleType::DAY_OF_YEAR: - // Plain format: day 0-365, Feb 29 counted - internal::day_of_year_to_month_day(rule.day, year, month, day); - break; - } - - // Calculate days from epoch to this date - int64_t days = 0; - for (int y = 1970; y < year; y++) { - days += internal::is_leap_year(y) ? 366 : 365; - } - for (int m = 1; m < month; m++) { - days += internal::days_in_month(year, m); - } - days += day - 1; - - // Convert to epoch and add transition time and base offset - return days * 86400 + rule.time_seconds + base_offset_seconds; -} - -bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { - if (!tz.has_dst) { - return false; - } - - int year = internal::epoch_to_year(utc_epoch); - - // Calculate DST start and end for this year - // DST start transition happens in standard time - time_t dst_start = calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds); - // DST end transition happens in daylight time - time_t dst_end = calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds); - - if (dst_start < dst_end) { - // Northern hemisphere: DST is between start and end - return (utc_epoch >= dst_start && utc_epoch < dst_end); - } else { - // Southern hemisphere: DST is outside the range (wraps around year) - return (utc_epoch >= dst_start || utc_epoch < dst_end); - } -} - bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; } // Determine DST status once (avoids duplicate is_in_dst calculation) - bool in_dst = is_in_dst(utc_epoch, tz); + bool in_dst = internal::is_in_dst(utc_epoch, tz); int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds; // Apply offset (POSIX offset is positive west, so subtract to get local) diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 8ab577dd83..ea9864b304 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -45,19 +45,6 @@ struct ParsedTimezone { /// @return true if parsing succeeded, false on error bool parse_posix_tz(const char *tz_string, ParsedTimezone &result); -/// Calculate the epoch timestamp for a DST transition in a given year. -/// @param year The year (e.g., 2026) -/// @param rule The DST rule (month, week, day_of_week, time) -/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context) -/// @return Unix epoch timestamp of the transition -time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds); - -/// Check if a given UTC epoch falls within DST for the parsed timezone. -/// @param utc_epoch Unix timestamp in UTC -/// @param tz The parsed timezone -/// @return true if DST is in effect at the given time -bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); - /// 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 @@ -112,6 +99,19 @@ bool is_leap_year(int year); /// Convert epoch to year/month/day/hour/min/sec (UTC) void epoch_to_tm_utc(time_t epoch, struct tm *out_tm); +/// Calculate the epoch timestamp for a DST transition in a given year. +/// @param year The year (e.g., 2026) +/// @param rule The DST rule (month, week, day_of_week, time) +/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context) +/// @return Unix epoch timestamp of the transition +time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds); + +/// Check if a given UTC epoch falls within DST for the parsed timezone. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @return true if DST is in effect at the given time +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); + } // namespace internal } // namespace esphome::time diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 3990d44ece..43c2dad9a7 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -369,7 +369,7 @@ TEST(PosixTzParser, DstStartUSEastern2026) { ParsedTimezone tz; parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - time_t dst_start = calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds); + time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds); struct tm tm; internal::epoch_to_tm_utc(dst_start, &tm); @@ -385,7 +385,7 @@ TEST(PosixTzParser, DstEndUSEastern2026) { ParsedTimezone tz; parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - time_t dst_end = calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds); + time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds); struct tm tm; internal::epoch_to_tm_utc(dst_end, &tm); @@ -404,7 +404,7 @@ TEST(PosixTzParser, LastSundayOfMarch2026) { rule.week = 5; rule.day_of_week = 0; rule.time_seconds = 2 * 3600; - time_t transition = calculate_dst_transition(2026, rule, 0); + time_t transition = internal::calculate_dst_transition(2026, rule, 0); struct tm tm; internal::epoch_to_tm_utc(transition, &tm); EXPECT_EQ(tm.tm_mday, 29); @@ -419,7 +419,7 @@ TEST(PosixTzParser, LastSundayOfOctober2026) { rule.week = 5; rule.day_of_week = 0; rule.time_seconds = 3 * 3600; - time_t transition = calculate_dst_transition(2026, rule, 0); + time_t transition = internal::calculate_dst_transition(2026, rule, 0); struct tm tm; internal::epoch_to_tm_utc(transition, &tm); EXPECT_EQ(tm.tm_mday, 25); @@ -434,7 +434,7 @@ TEST(PosixTzParser, FirstSundayOfApril2026) { rule.week = 1; rule.day_of_week = 0; rule.time_seconds = 0; - time_t transition = calculate_dst_transition(2026, rule, 0); + time_t transition = internal::calculate_dst_transition(2026, rule, 0); struct tm tm; internal::epoch_to_tm_utc(transition, &tm); EXPECT_EQ(tm.tm_mday, 5); @@ -451,7 +451,7 @@ TEST(PosixTzParser, IsInDstUSEasternSummer) { // July 4, 2026 12:00 UTC - definitely in DST time_t summer = make_utc(2026, 7, 4, 12); - EXPECT_TRUE(is_in_dst(summer, tz)); + EXPECT_TRUE(internal::is_in_dst(summer, tz)); } TEST(PosixTzParser, IsInDstUSEasternWinter) { @@ -460,7 +460,7 @@ TEST(PosixTzParser, IsInDstUSEasternWinter) { // January 15, 2026 12:00 UTC - definitely not in DST time_t winter = make_utc(2026, 1, 15, 12); - EXPECT_FALSE(is_in_dst(winter, tz)); + EXPECT_FALSE(internal::is_in_dst(winter, tz)); } TEST(PosixTzParser, IsInDstNoDstTimezone) { @@ -469,7 +469,7 @@ TEST(PosixTzParser, IsInDstNoDstTimezone) { // July 15, 2026 12:00 UTC time_t epoch = make_utc(2026, 7, 15, 12); - EXPECT_FALSE(is_in_dst(epoch, tz)); + EXPECT_FALSE(internal::is_in_dst(epoch, tz)); } TEST(PosixTzParser, SouthernHemisphereDstSummer) { @@ -478,7 +478,7 @@ TEST(PosixTzParser, SouthernHemisphereDstSummer) { // December 15, 2025 12:00 UTC - summer in NZ, should be in DST time_t nz_summer = make_utc(2025, 12, 15, 12); - EXPECT_TRUE(is_in_dst(nz_summer, tz)); + EXPECT_TRUE(internal::is_in_dst(nz_summer, tz)); } TEST(PosixTzParser, SouthernHemisphereDstWinter) { @@ -487,7 +487,7 @@ TEST(PosixTzParser, SouthernHemisphereDstWinter) { // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST time_t nz_winter = make_utc(2026, 7, 15, 12); - EXPECT_FALSE(is_in_dst(nz_winter, tz)); + EXPECT_FALSE(internal::is_in_dst(nz_winter, tz)); } // ============================================================================ @@ -607,11 +607,11 @@ TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) time_t before_epoch = make_utc(2026, 3, 8, 6, 59, 59); - EXPECT_FALSE(is_in_dst(before_epoch, tz)); + EXPECT_FALSE(internal::is_in_dst(before_epoch, tz)); // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) time_t after_epoch = make_utc(2026, 3, 8, 7); - EXPECT_TRUE(is_in_dst(after_epoch, tz)); + EXPECT_TRUE(internal::is_in_dst(after_epoch, tz)); } TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { @@ -621,11 +621,11 @@ TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) time_t before_epoch = make_utc(2026, 11, 1, 5, 59, 59); - EXPECT_TRUE(is_in_dst(before_epoch, tz)); + EXPECT_TRUE(internal::is_in_dst(before_epoch, tz)); // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) time_t after_epoch = make_utc(2026, 11, 1, 6); - EXPECT_FALSE(is_in_dst(after_epoch, tz)); + EXPECT_FALSE(internal::is_in_dst(after_epoch, tz)); } } // namespace esphome::time::testing