1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00
This commit is contained in:
J. Nick Koston
2026-01-29 22:07:34 -06:00
parent 34ec72ad49
commit d2bc168f39
3 changed files with 102 additions and 102 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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