From aad3764806da6706f271b095b080131c7908fa47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:14:42 -0600 Subject: [PATCH 01/61] posix_tz --- esphome/core/posix_tz.cpp | 493 ++++++++++++ esphome/core/posix_tz.h | 128 ++++ tests/unit_tests/test_posix_tz_parser.cpp | 884 ++++++++++++++++++++++ 3 files changed, 1505 insertions(+) create mode 100644 esphome/core/posix_tz.cpp create mode 100644 esphome/core/posix_tz.h create mode 100644 tests/unit_tests/test_posix_tz_parser.cpp diff --git a/esphome/core/posix_tz.cpp b/esphome/core/posix_tz.cpp new file mode 100644 index 0000000000..1de9acd308 --- /dev/null +++ b/esphome/core/posix_tz.cpp @@ -0,0 +1,493 @@ +#include "posix_tz.h" +#include + +namespace esphome { + +namespace internal { + +bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } + +int days_in_month(int year, int month) { + static const int DAYS_PER_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2 && is_leap_year(year)) + return 29; + return DAYS_PER_MONTH[month - 1]; +} + +// Zeller-like algorithm for day of week (0 = Sunday) +int day_of_week(int year, int month, int day) { + // Adjust for January/February + if (month < 3) { + month += 12; + year--; + } + int k = year % 100; + int j = year / 100; + int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7; + // Convert from Zeller (0=Sat) to standard (0=Sun) + return ((h + 6) % 7); +} + +void 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; + if (remaining_secs < 0) { + days--; + remaining_secs += 86400; + } + + out_tm->tm_sec = remaining_secs % 60; + remaining_secs /= 60; + out_tm->tm_min = remaining_secs % 60; + out_tm->tm_hour = remaining_secs / 60; + + // Day of week (Jan 1, 1970 was Thursday = 4) + out_tm->tm_wday = static_cast((days + 4) % 7); + if (out_tm->tm_wday < 0) + out_tm->tm_wday += 7; + + // Calculate year, month, day + int year = 1970; + while (days >= (is_leap_year(year) ? 366 : 365)) { + days -= is_leap_year(year) ? 366 : 365; + year++; + } + while (days < 0) { + year--; + days += is_leap_year(year) ? 366 : 365; + } + + out_tm->tm_year = year - 1900; + out_tm->tm_yday = static_cast(days); + + int month = 1; + while (days >= days_in_month(year, month)) { + days -= days_in_month(year, month); + month++; + } + + out_tm->tm_mon = month - 1; + out_tm->tm_mday = static_cast(days) + 1; + out_tm->tm_isdst = 0; +} + +time_t tm_to_epoch_utc(const struct tm *tm) { + int year = tm->tm_year + 1900; + int month = tm->tm_mon + 1; + int day = tm->tm_mday; + + // Days from epoch to start of year + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += is_leap_year(y) ? 366 : 365; + } + + // Days from start of year to start of month + for (int m = 1; m < month; m++) { + days += days_in_month(year, m); + } + + // Days in current month + days += day - 1; + + return days * 86400 + tm->tm_hour * 3600 + tm->tm_min * 60 + tm->tm_sec; +} + +bool skip_tz_name(const char *&p) { + if (*p == '<') { + // Angle-bracket quoted name: <+07>, <-03>, + 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(*p))) { + p++; + } + return (p - start) >= 3; +} + +int32_t parse_offset(const char *&p) { + int sign = 1; + if (*p == '-') { + sign = -1; + p++; + } else if (*p == '+') { + p++; + } + + // Parse hours + int hours = 0; + while (*p && std::isdigit(static_cast(*p))) { + hours = hours * 10 + (*p - '0'); + p++; + } + + int minutes = 0; + int seconds = 0; + + // Optional :mm + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + minutes = minutes * 10 + (*p - '0'); + p++; + } + + // Optional :ss + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + seconds = seconds * 10 + (*p - '0'); + p++; + } + } + } + + return sign * (hours * 3600 + minutes * 60 + seconds); +} + +// Helper to parse the optional /time suffix +static void parse_transition_time(const char *&p, DSTRule &rule) { + rule.time_seconds = 2 * 3600; // Default 02:00 + + if (*p == '/') { + p++; + // Parse time as [+-]hh[:mm[:ss]] + int sign = 1; + if (*p == '-') { + sign = -1; + p++; + } else if (*p == '+') { + p++; + } + + int hours = 0; + while (*p && std::isdigit(static_cast(*p))) { + hours = hours * 10 + (*p - '0'); + p++; + } + + int minutes = 0; + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + minutes = minutes * 10 + (*p - '0'); + p++; + } + } + + int seconds = 0; + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + seconds = seconds * 10 + (*p - '0'); + p++; + } + } + + rule.time_seconds = sign * (hours * 3600 + minutes * 60 + seconds); + } +} + +void julian_to_month_day(int julian_day, int year, 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 + static const int DAYS_BEFORE_MONTH[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + + out_month = 1; + for (int m = 11; m >= 0; m--) { + if (julian_day > DAYS_BEFORE_MONTH[m]) { + out_month = m + 1; + out_day = julian_day - DAYS_BEFORE_MONTH[m]; + return; + } + } + out_day = julian_day; +} + +void day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) { + // Plain format: day 0-365, Feb 29 IS counted in leap years + // Day 0 = Jan 1 + int remaining = day_of_year; + out_month = 1; + + while (out_month <= 12) { + int days_this_month = days_in_month(year, out_month); + if (remaining < days_this_month) { + out_day = remaining + 1; + return; + } + remaining -= days_this_month; + out_month++; + } + + // Shouldn't reach here with valid input + out_month = 12; + out_day = 31; +} + +bool parse_dst_rule(const char *&p, DSTRule &rule) { + // Initialize defaults + rule = {}; + rule.time_seconds = 2 * 3600; // Default 02:00 + + if (*p == 'M' || *p == 'm') { + // M format: Mm.w.d (month.week.day) + rule.type = DSTRuleType::MONTH_WEEK_DAY; + p++; + + // Parse month + rule.month = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.month = rule.month * 10 + (*p - '0'); + p++; + } + if (rule.month < 1 || rule.month > 12) + return false; + + if (*p != '.') + return false; + p++; + + // Parse week (1-5, where 5 means "last") + rule.week = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.week = rule.week * 10 + (*p - '0'); + p++; + } + if (rule.week < 1 || rule.week > 5) + return false; + + if (*p != '.') + return false; + p++; + + // Parse day of week (0 = Sunday) + rule.day_of_week = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day_of_week = rule.day_of_week * 10 + (*p - '0'); + 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 = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day = rule.day * 10 + (*p - '0'); + p++; + } + if (rule.day < 1 || rule.day > 365) + return false; + + } else if (std::isdigit(static_cast(*p))) { + // Plain number format: n (day 0-365, counting Feb 29) + rule.type = DSTRuleType::DAY_OF_YEAR; + + rule.day = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day = rule.day * 10 + (*p - '0'); + p++; + } + if (rule.day > 365) + return false; + + } else { + return false; + } + + // Parse optional /time suffix + parse_transition_time(p, rule); + + return true; +} + +} // namespace internal + +bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { + if (!tz_string || !*tz_string) { + return false; + } + + const char *p = tz_string; + + // Initialize result + result.std_offset_seconds = 0; + result.dst_offset_seconds = 0; + result.has_dst = false; + 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(*p)) && *p != '+' && *p != '-')) { + return false; + } + result.std_offset_seconds = internal::parse_offset(p); + + // Check for DST name + if (!*p) { + // No DST + result.has_dst = false; + return true; + } + + // If next char is comma, there's no DST name but there are rules (invalid) + if (*p == ',') { + return false; + } + + if (!internal::skip_tz_name(p)) { + // No valid DST name, no DST + result.has_dst = false; + return true; + } + + // We have a DST name + result.has_dst = true; + + // Optional DST offset (default is std - 1 hour) + if (*p && *p != ',' && (std::isdigit(static_cast(*p)) || *p == '+' || *p == '-')) { + result.dst_offset_seconds = internal::parse_offset(p); + } else { + result.dst_offset_seconds = result.std_offset_seconds - 3600; + } + + // Parse DST rules if present + if (*p == ',') { + p++; + if (!internal::parse_dst_rule(p, result.dst_start)) { + return false; + } + + if (*p == ',') { + p++; + if (!internal::parse_dst_rule(p, result.dst_end)) { + return false; + } + } + } + + 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, year, 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; + } + + // Build the tm struct for this date at the transition time + struct tm transition_tm = {}; + transition_tm.tm_year = year - 1900; + transition_tm.tm_mon = month - 1; + transition_tm.tm_mday = day; + transition_tm.tm_hour = rule.time_seconds / 3600; + transition_tm.tm_min = (rule.time_seconds % 3600) / 60; + transition_tm.tm_sec = rule.time_seconds % 60; + + // Convert to UTC epoch, then add the base offset + // (transition times are specified in local time before the transition) + time_t local_epoch = internal::tm_to_epoch_utc(&transition_tm); + return local_epoch + base_offset_seconds; +} + +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { + if (!tz.has_dst) { + return false; + } + + // Get the year from the UTC epoch + struct tm utc_tm; + internal::epoch_to_tm_utc(utc_epoch, &utc_tm); + int year = utc_tm.tm_year + 1900; + + // 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); + } +} + +int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz) { + if (is_in_dst(utc_epoch, tz)) { + return tz.dst_offset_seconds; + } + return tz.std_offset_seconds; +} + +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { + if (!out_tm) { + return false; + } + + int32_t offset = get_utc_offset(utc_epoch, tz); + + // Apply offset (POSIX offset is positive west, so subtract to get local) + time_t local_epoch = utc_epoch - offset; + + internal::epoch_to_tm_utc(local_epoch, out_tm); + out_tm->tm_isdst = is_in_dst(utc_epoch, tz) ? 1 : 0; + + return true; +} + +} // namespace esphome diff --git a/esphome/core/posix_tz.h b/esphome/core/posix_tz.h new file mode 100644 index 0000000000..6d76c4142c --- /dev/null +++ b/esphome/core/posix_tz.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +/// Type of DST transition rule +enum class DSTRuleType : uint8_t { + MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March) + JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted) + DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years) +}; + +/// Rule for DST transition +struct DSTRule { + DSTRuleType type; ///< Type of rule + uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY) + uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY) + uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) + uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) + int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM) +}; + +/// Parsed POSIX timezone information +struct ParsedTimezone { + int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west) + int32_t dst_offset_seconds; ///< DST offset from UTC in seconds + DSTRule dst_start; ///< When DST starts + DSTRule dst_end; ///< When DST ends + bool has_dst; ///< Whether this timezone has DST +}; + +/// Parse a POSIX TZ string into a ParsedTimezone struct. +/// 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); + +/// 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 +/// @param tz The parsed timezone +/// @param[out] out_tm Output tm struct with local time +/// @return true on success +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm); + +/// Get the current offset from UTC in seconds for a given epoch. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @return Offset in seconds (positive = behind UTC, negative = ahead) +int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz); + +// Internal helper functions exposed for testing + +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 year The year (for leap year calculation) +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void julian_to_month_day(int julian_day, int year, int &month, int &day); + +/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day +/// @param day_of_year Day number 0-365 +/// @param year The year (for leap year calculation) +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day); + +/// Calculate day of week for any date (0 = Sunday) +/// Uses a simplified algorithm that works for years 1970-2099 +int day_of_week(int year, int month, int day); + +/// Get the number of days in a month +int days_in_month(int year, int month); + +/// Check if a year is a leap year +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); + +/// Convert tm struct to epoch (UTC) +time_t tm_to_epoch_utc(const struct tm *tm); + +} // namespace internal + +} // namespace esphome diff --git a/tests/unit_tests/test_posix_tz_parser.cpp b/tests/unit_tests/test_posix_tz_parser.cpp new file mode 100644 index 0000000000..dbc0ecf380 --- /dev/null +++ b/tests/unit_tests/test_posix_tz_parser.cpp @@ -0,0 +1,884 @@ +// Test POSIX TZ parser implementation +// Compile with: g++ -std=gnu++20 -I../../esphome/core -o test_posix_tz_parser test_posix_tz_parser.cpp +// ../../esphome/core/posix_tz.cpp && ./test_posix_tz_parser +// +// This test verifies our custom POSIX TZ parser produces identical results to libc's +// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). +// +// Key test cases include: +// - Angle-bracket timezone notation (<+07>-7) - see espressif/newlib-esp32#8 +// - Half-hour offsets (IST-5:30) +// - Southern hemisphere DST (start month > end month) +// - DST transition boundary conditions + +#include +#include +#include +#include +#include + +// Include the implementation directly for standalone compilation +#include "../../esphome/core/posix_tz.h" +#include "../../esphome/core/posix_tz.cpp" + +using namespace esphome; + +#define TEST(name) static void test_##name() +#define RUN_TEST(name) \ + do { \ + printf(" " #name "..."); \ + fflush(stdout); \ + test_##name(); \ + printf(" OK\n"); \ + } while (0) + +// ============================================================================ +// Basic TZ string parsing tests +// ============================================================================ + +TEST(parse_simple_offset_est5) { + ParsedTimezone tz; + assert(parse_posix_tz("EST5", tz)); + assert(tz.std_offset_seconds == 5 * 3600); // +5 hours (west of UTC) + assert(!tz.has_dst); +} + +TEST(parse_negative_offset_cet) { + ParsedTimezone tz; + assert(parse_posix_tz("CET-1", tz)); + assert(tz.std_offset_seconds == -1 * 3600); // -1 hour (east of UTC) + assert(!tz.has_dst); +} + +TEST(parse_explicit_positive_offset) { + ParsedTimezone tz; + assert(parse_posix_tz("TEST+5", tz)); + assert(tz.std_offset_seconds == 5 * 3600); + assert(!tz.has_dst); +} + +TEST(parse_zero_offset) { + ParsedTimezone tz; + assert(parse_posix_tz("UTC0", tz)); + assert(tz.std_offset_seconds == 0); + assert(!tz.has_dst); +} + +TEST(parse_us_eastern_with_dst) { + ParsedTimezone tz; + assert(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); + assert(tz.std_offset_seconds == 5 * 3600); + assert(tz.dst_offset_seconds == 4 * 3600); // Default: STD - 1hr + assert(tz.has_dst); + assert(tz.dst_start.month == 3); + assert(tz.dst_start.week == 2); + assert(tz.dst_start.day_of_week == 0); // Sunday + assert(tz.dst_start.time_seconds == 2 * 3600); // Default 2:00 AM + assert(tz.dst_end.month == 11); + assert(tz.dst_end.week == 1); + assert(tz.dst_end.day_of_week == 0); +} + +TEST(parse_us_central_with_time) { + ParsedTimezone tz; + assert(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); + assert(tz.std_offset_seconds == 6 * 3600); + assert(tz.dst_offset_seconds == 5 * 3600); + assert(tz.dst_start.time_seconds == 2 * 3600); // 2:00 AM + assert(tz.dst_end.time_seconds == 2 * 3600); +} + +TEST(parse_europe_berlin) { + ParsedTimezone tz; + assert(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); + assert(tz.std_offset_seconds == -1 * 3600); + assert(tz.dst_offset_seconds == -2 * 3600); // Default: STD - 1hr + assert(tz.has_dst); + assert(tz.dst_start.month == 3); + assert(tz.dst_start.week == 5); // Last week + assert(tz.dst_end.month == 10); + assert(tz.dst_end.week == 5); // Last week + assert(tz.dst_end.time_seconds == 3 * 3600); // 3:00 AM +} + +TEST(parse_new_zealand) { + ParsedTimezone tz; + // Southern hemisphere - DST starts in Sept, ends in April + assert(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); + assert(tz.std_offset_seconds == -12 * 3600); + assert(tz.dst_offset_seconds == -13 * 3600); // Default: STD - 1hr + assert(tz.has_dst); + assert(tz.dst_start.month == 9); // September + assert(tz.dst_end.month == 4); // April +} + +TEST(parse_explicit_dst_offset) { + ParsedTimezone tz; + // Some places have non-standard DST offsets + assert(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); + assert(tz.std_offset_seconds == 5 * 3600); + assert(tz.dst_offset_seconds == 4 * 3600); + assert(tz.has_dst); +} + +// ============================================================================ +// Angle-bracket notation tests (espressif/newlib-esp32#8) +// ============================================================================ + +TEST(parse_angle_bracket_positive) { + // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) + ParsedTimezone tz; + assert(parse_posix_tz("<+07>-7", tz)); + assert(tz.std_offset_seconds == -7 * 3600); // -7 = 7 hours east of UTC + assert(!tz.has_dst); +} + +TEST(parse_angle_bracket_negative) { + // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) + ParsedTimezone tz; + assert(parse_posix_tz("<-03>3", tz)); + assert(tz.std_offset_seconds == 3 * 3600); + assert(!tz.has_dst); +} + +TEST(parse_angle_bracket_with_dst) { + // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) + ParsedTimezone tz; + assert(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); + assert(tz.std_offset_seconds == -10 * 3600); + assert(tz.dst_offset_seconds == -11 * 3600); + assert(tz.has_dst); + assert(tz.dst_start.month == 10); + assert(tz.dst_end.month == 4); +} + +TEST(parse_angle_bracket_named) { + // -10 (Australian Eastern Standard Time) + ParsedTimezone tz; + assert(parse_posix_tz("-10", tz)); + assert(tz.std_offset_seconds == -10 * 3600); + assert(!tz.has_dst); +} + +TEST(parse_angle_bracket_with_minutes) { + // <+0545>-5:45 (Nepal) + ParsedTimezone tz; + assert(parse_posix_tz("<+0545>-5:45", tz)); + assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); + assert(!tz.has_dst); +} + +// ============================================================================ +// Half-hour and unusual offset tests +// ============================================================================ + +TEST(parse_offset_with_minutes_india) { + ParsedTimezone tz; + // India: UTC+5:30 + assert(parse_posix_tz("IST-5:30", tz)); + assert(tz.std_offset_seconds == -(5 * 3600 + 30 * 60)); + assert(!tz.has_dst); +} + +TEST(parse_offset_with_minutes_nepal) { + ParsedTimezone tz; + // Nepal: UTC+5:45 + assert(parse_posix_tz("NPT-5:45", tz)); + assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); + assert(!tz.has_dst); +} + +TEST(parse_offset_with_seconds) { + ParsedTimezone tz; + // Unusual but valid: offset with seconds + assert(parse_posix_tz("TEST-1:30:30", tz)); + assert(tz.std_offset_seconds == -(1 * 3600 + 30 * 60 + 30)); +} + +TEST(parse_chatham_islands) { + // Chatham Islands: UTC+12:45 with DST + ParsedTimezone tz; + assert(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); + assert(tz.std_offset_seconds == -(12 * 3600 + 45 * 60)); + assert(tz.dst_offset_seconds == -(13 * 3600 + 45 * 60)); + assert(tz.has_dst); +} + +// ============================================================================ +// Invalid input tests +// ============================================================================ + +TEST(parse_empty_string_fails) { + ParsedTimezone tz; + assert(!parse_posix_tz("", tz)); +} + +TEST(parse_null_fails) { + ParsedTimezone tz; + assert(!parse_posix_tz(nullptr, tz)); +} + +TEST(parse_short_name_fails) { + ParsedTimezone tz; + // TZ name must be at least 3 characters + assert(!parse_posix_tz("AB5", tz)); +} + +TEST(parse_missing_offset_fails) { + ParsedTimezone tz; + assert(!parse_posix_tz("EST", tz)); +} + +TEST(parse_unterminated_bracket_fails) { + ParsedTimezone tz; + assert(!parse_posix_tz("<+07-7", tz)); // Missing closing > +} + +// ============================================================================ +// J-format and plain day number tests +// ============================================================================ + +TEST(parse_j_format_basic) { + ParsedTimezone tz; + // J format: Julian day 1-365, not counting Feb 29 + assert(parse_posix_tz("EST5EDT,J60,J305", tz)); + assert(tz.has_dst); + assert(tz.dst_start.type == DSTRuleType::JULIAN_NO_LEAP); + assert(tz.dst_start.day == 60); // March 1 + assert(tz.dst_end.type == DSTRuleType::JULIAN_NO_LEAP); + assert(tz.dst_end.day == 305); // November 1 +} + +TEST(parse_j_format_with_time) { + ParsedTimezone tz; + assert(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); + assert(tz.dst_start.day == 60); + assert(tz.dst_start.time_seconds == 2 * 3600); + assert(tz.dst_end.day == 305); + assert(tz.dst_end.time_seconds == 2 * 3600); +} + +TEST(parse_plain_day_number) { + ParsedTimezone tz; + // Plain format: day 0-365, counting Feb 29 in leap years + assert(parse_posix_tz("EST5EDT,59,304", tz)); + assert(tz.has_dst); + assert(tz.dst_start.type == DSTRuleType::DAY_OF_YEAR); + assert(tz.dst_start.day == 59); // Feb 29 or March 1 depending on leap year + assert(tz.dst_end.type == DSTRuleType::DAY_OF_YEAR); + assert(tz.dst_end.day == 304); +} + +TEST(parse_plain_day_number_with_time) { + ParsedTimezone tz; + assert(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); + assert(tz.dst_start.day == 59); + assert(tz.dst_start.time_seconds == 3 * 3600); + assert(tz.dst_end.day == 304); + assert(tz.dst_end.time_seconds == 1 * 3600 + 30 * 60); +} + +TEST(j_format_invalid_day_zero) { + ParsedTimezone tz; + // J format day must be 1-365, not 0 + assert(!parse_posix_tz("EST5EDT,J0,J305", tz)); +} + +TEST(j_format_invalid_day_366) { + ParsedTimezone tz; + // J format day must be 1-365 + assert(!parse_posix_tz("EST5EDT,J366,J305", tz)); +} + +TEST(plain_day_invalid_day_366) { + ParsedTimezone tz; + // Plain format day must be 0-365 + assert(!parse_posix_tz("EST5EDT,366,304", tz)); +} + +// ============================================================================ +// Julian day to month/day conversion tests +// ============================================================================ + +TEST(julian_day_60_is_march_1) { + // J60 is always March 1, regardless of leap year + int month, day; + internal::julian_to_month_day(60, 2024, month, day); // Leap year + assert(month == 3 && day == 1); + internal::julian_to_month_day(60, 2025, month, day); // Non-leap year + assert(month == 3 && day == 1); +} + +TEST(julian_day_1_is_jan_1) { + int month, day; + internal::julian_to_month_day(1, 2025, month, day); + assert(month == 1 && day == 1); +} + +TEST(julian_day_365_is_dec_31) { + int month, day; + internal::julian_to_month_day(365, 2025, month, day); + assert(month == 12 && day == 31); +} + +TEST(day_of_year_59_differs_by_leap) { + int month, day; + // Day 59 in leap year is Feb 29 + internal::day_of_year_to_month_day(59, 2024, month, day); + assert(month == 2 && day == 29); + // Day 59 in non-leap year is March 1 + internal::day_of_year_to_month_day(59, 2025, month, day); + assert(month == 3 && day == 1); +} + +TEST(day_of_year_0_is_jan_1) { + int month, day; + internal::day_of_year_to_month_day(0, 2025, month, day); + assert(month == 1 && day == 1); +} + +// ============================================================================ +// Day of week calculation tests +// ============================================================================ + +TEST(day_of_week_known_dates) { + // January 1, 1970 was Thursday (4) + assert(internal::day_of_week(1970, 1, 1) == 4); + + // July 4, 1776 was Thursday (4) + assert(internal::day_of_week(1776, 7, 4) == 4); + + // January 1, 2000 was Saturday (6) + assert(internal::day_of_week(2000, 1, 1) == 6); + + // September 11, 2001 was Tuesday (2) + assert(internal::day_of_week(2001, 9, 11) == 2); + + // March 8, 2026 is Sunday (0) - US DST start + assert(internal::day_of_week(2026, 3, 8) == 0); + + // November 1, 2026 is Sunday (0) - US DST end + assert(internal::day_of_week(2026, 11, 1) == 0); +} + +TEST(leap_year_detection) { + assert(!internal::is_leap_year(1900)); // Divisible by 100 but not 400 + assert(internal::is_leap_year(2000)); // Divisible by 400 + assert(internal::is_leap_year(2024)); // Divisible by 4 + assert(!internal::is_leap_year(2025)); // Not divisible by 4 + assert(internal::is_leap_year(2028)); +} + +TEST(days_in_month_regular) { + assert(internal::days_in_month(2025, 1) == 31); + assert(internal::days_in_month(2025, 2) == 28); + assert(internal::days_in_month(2025, 4) == 30); + assert(internal::days_in_month(2025, 12) == 31); +} + +TEST(days_in_month_leap_year) { + assert(internal::days_in_month(2024, 2) == 29); + assert(internal::days_in_month(2025, 2) == 28); +} + +// ============================================================================ +// DST transition calculation tests +// ============================================================================ + +TEST(dst_start_us_eastern_2026) { + // March 8, 2026 is 2nd Sunday of March + 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); + struct tm tm; + internal::epoch_to_tm_utc(dst_start, &tm); + + // At 2:00 AM EST (UTC-5), so 7:00 AM UTC + assert(tm.tm_year + 1900 == 2026); + assert(tm.tm_mon + 1 == 3); // March + assert(tm.tm_mday == 8); // 8th + assert(tm.tm_hour == 7); // 7:00 UTC = 2:00 EST +} + +TEST(dst_end_us_eastern_2026) { + // November 1, 2026 is 1st Sunday of November + 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); + struct tm tm; + internal::epoch_to_tm_utc(dst_end, &tm); + + // At 2:00 AM EDT (UTC-4), so 6:00 AM UTC + assert(tm.tm_year + 1900 == 2026); + assert(tm.tm_mon + 1 == 11); // November + assert(tm.tm_mday == 1); // 1st + assert(tm.tm_hour == 6); // 6:00 UTC = 2:00 EDT +} + +TEST(last_sunday_of_march_2026) { + // Europe: M3.5.0 = last Sunday of March = March 29, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + assert(tm.tm_mday == 29); + assert(tm.tm_wday == 0); // Sunday +} + +TEST(last_sunday_of_october_2026) { + // Europe: M10.5.0 = last Sunday of October = October 25, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 10; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 3 * 3600; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + assert(tm.tm_mday == 25); + assert(tm.tm_wday == 0); // Sunday +} + +TEST(first_sunday_of_april_2026) { + // April 5, 2026 is 1st Sunday + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 4; + rule.week = 1; + rule.day_of_week = 0; + rule.time_seconds = 0; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + assert(tm.tm_mday == 5); + assert(tm.tm_wday == 0); +} + +// ============================================================================ +// is_in_dst tests +// ============================================================================ + +TEST(is_in_dst_us_eastern_summer) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 12:00 UTC - definitely in DST + struct tm july4 {}; + july4.tm_sec = 0; + july4.tm_min = 0; + july4.tm_hour = 12; + july4.tm_mday = 4; + july4.tm_mon = 6; + july4.tm_year = 126; + time_t summer = internal::tm_to_epoch_utc(&july4); + assert(is_in_dst(summer, tz) == true); +} + +TEST(is_in_dst_us_eastern_winter) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // January 15, 2026 12:00 UTC - definitely not in DST + struct tm jan15 {}; + jan15.tm_sec = 0; + jan15.tm_min = 0; + jan15.tm_hour = 12; + jan15.tm_mday = 15; + jan15.tm_mon = 0; + jan15.tm_year = 126; + time_t winter = internal::tm_to_epoch_utc(&jan15); + assert(is_in_dst(winter, tz) == false); +} + +TEST(is_in_dst_no_dst_timezone) { + ParsedTimezone tz; + parse_posix_tz("IST-5:30", tz); + + struct tm anytime {}; + anytime.tm_sec = 0; + anytime.tm_min = 0; + anytime.tm_hour = 12; + anytime.tm_mday = 15; + anytime.tm_mon = 6; + anytime.tm_year = 126; + time_t epoch = internal::tm_to_epoch_utc(&anytime); + assert(is_in_dst(epoch, tz) == false); +} + +TEST(southern_hemisphere_dst_summer) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // December 15, 2025 12:00 UTC - summer in NZ, should be in DST + struct tm dec15 {}; + dec15.tm_sec = 0; + dec15.tm_min = 0; + dec15.tm_hour = 12; + dec15.tm_mday = 15; + dec15.tm_mon = 11; + dec15.tm_year = 125; + time_t nz_summer = internal::tm_to_epoch_utc(&dec15); + assert(is_in_dst(nz_summer, tz) == true); +} + +TEST(southern_hemisphere_dst_winter) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST + struct tm july15 {}; + july15.tm_sec = 0; + july15.tm_min = 0; + july15.tm_hour = 12; + july15.tm_mday = 15; + july15.tm_mon = 6; + july15.tm_year = 126; + time_t nz_winter = internal::tm_to_epoch_utc(&july15); + assert(is_in_dst(nz_winter, tz) == false); +} + +// ============================================================================ +// epoch_to_local_tm tests +// ============================================================================ + +TEST(epoch_to_local_basic) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC + struct tm local; + assert(epoch_to_local_tm(epoch, tz, &local)); + assert(local.tm_year == 70); + assert(local.tm_mon == 0); + assert(local.tm_mday == 1); + assert(local.tm_hour == 0); +} + +TEST(epoch_to_local_with_offset) { + ParsedTimezone tz; + parse_posix_tz("EST5", tz); // UTC-5 + + // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST + struct tm utc_tm {}; + utc_tm.tm_sec = 0; + utc_tm.tm_min = 0; + utc_tm.tm_hour = 5; + utc_tm.tm_mday = 1; + utc_tm.tm_mon = 0; + utc_tm.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); + + struct tm local; + assert(epoch_to_local_tm(utc_epoch, tz, &local)); + assert(local.tm_hour == 0); // Midnight EST + assert(local.tm_mday == 1); + assert(local.tm_isdst == 0); +} + +TEST(epoch_to_local_dst_transition) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 16:00 UTC = 12:00 EDT (noon) + struct tm july4_utc {}; + july4_utc.tm_sec = 0; + july4_utc.tm_min = 0; + july4_utc.tm_hour = 16; + july4_utc.tm_mday = 4; + july4_utc.tm_mon = 6; + july4_utc.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); + + struct tm local; + assert(epoch_to_local_tm(utc_epoch, tz, &local)); + assert(local.tm_hour == 12); // Noon EDT + assert(local.tm_isdst == 1); +} + +// ============================================================================ +// Verification against libc (run on desktop only) +// ============================================================================ + +// Helper to compare our implementation against libc +static bool verify_against_libc(const char *tz_str, time_t epoch) { + ParsedTimezone tz; + if (!parse_posix_tz(tz_str, tz)) { + printf("Failed to parse TZ: %s\n", tz_str); + return false; + } + + // Our implementation + struct tm our_tm; + epoch_to_local_tm(epoch, tz, &our_tm); + + // libc implementation + setenv("TZ", tz_str, 1); + tzset(); + struct tm *libc_tm = localtime(&epoch); + + bool match = + (our_tm.tm_year == libc_tm->tm_year && our_tm.tm_mon == libc_tm->tm_mon && our_tm.tm_mday == libc_tm->tm_mday && + our_tm.tm_hour == libc_tm->tm_hour && our_tm.tm_min == libc_tm->tm_min && our_tm.tm_sec == libc_tm->tm_sec && + our_tm.tm_isdst == libc_tm->tm_isdst); + + if (!match) { + printf("\nMismatch for TZ=%s epoch=%ld\n", tz_str, (long) epoch); + printf(" Our: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", our_tm.tm_year + 1900, our_tm.tm_mon + 1, our_tm.tm_mday, + our_tm.tm_hour, our_tm.tm_min, our_tm.tm_sec, our_tm.tm_isdst); + printf(" libc: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", libc_tm->tm_year + 1900, libc_tm->tm_mon + 1, + libc_tm->tm_mday, libc_tm->tm_hour, libc_tm->tm_min, libc_tm->tm_sec, libc_tm->tm_isdst); + } + + return match; +} + +TEST(verify_us_eastern_multiple_epochs) { + const char *tz_str = "EST5EDT,M3.2.0/2,M11.1.0/2"; + // Test various dates throughout the year + time_t epochs[] = { + 1704067200, // Jan 1, 2024 00:00 UTC + 1711900800, // March 31, 2024 12:00 UTC (after DST start) + 1720000000, // July 3, 2024 (summer) + 1730419200, // Nov 1, 2024 00:00 UTC (DST end day) + 1735689600, // Jan 1, 2025 00:00 UTC + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_us_central_multiple_epochs) { + const char *tz_str = "CST6CDT,M3.2.0/2,M11.1.0/2"; + time_t epochs[] = { + 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_europe_berlin_multiple_epochs) { + const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0/3"; + time_t epochs[] = { + 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_angle_bracket_notation) { + // This was the bug in espressif/newlib-esp32#8 + const char *tz_str = "<+07>-7"; + time_t epochs[] = { + 1704067200, + 1720000000, + 1735689600, + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_india_half_hour) { + const char *tz_str = "IST-5:30"; + time_t epochs[] = { + 1704067200, + 1720000000, + 1735689600, + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_new_zealand_southern_hemisphere) { + const char *tz_str = "NZST-12NZDT,M9.5.0,M4.1.0/3"; + time_t epochs[] = { + 1704067200, // Jan (NZ summer, DST) + 1720000000, // July (NZ winter, no DST) + 1735689600, // Jan (NZ summer, DST) + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +TEST(verify_australia_sydney) { + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0/3"; + time_t epochs[] = { + 1704067200, + 1720000000, + 1735689600, + }; + for (time_t epoch : epochs) { + assert(verify_against_libc(tz_str, epoch)); + } +} + +// ============================================================================ +// DST boundary edge cases +// ============================================================================ + +TEST(dst_boundary_just_before_spring_forward) { + // Test 1 second before DST starts + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 6; + before.tm_mday = 8; + before.tm_mon = 2; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + assert(is_in_dst(before_epoch, tz) == false); + + // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) + struct tm after {}; + after.tm_sec = 0; + after.tm_min = 0; + after.tm_hour = 7; + after.tm_mday = 8; + after.tm_mon = 2; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + assert(is_in_dst(after_epoch, tz) == true); +} + +TEST(dst_boundary_just_before_fall_back) { + // Test 1 second before DST ends + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 5; + before.tm_mday = 1; + before.tm_mon = 10; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + assert(is_in_dst(before_epoch, tz) == true); + + // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) + struct tm after {}; + after.tm_sec = 0; + after.tm_min = 0; + after.tm_hour = 6; + after.tm_mday = 1; + after.tm_mon = 10; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + assert(is_in_dst(after_epoch, tz) == false); +} + +// ============================================================================ +// Main +// ============================================================================ + +int main() { + printf("POSIX TZ Parser Unit Tests\n"); + printf("==========================\n\n"); + + printf("Basic TZ string parsing:\n"); + RUN_TEST(parse_simple_offset_est5); + RUN_TEST(parse_negative_offset_cet); + RUN_TEST(parse_explicit_positive_offset); + RUN_TEST(parse_zero_offset); + RUN_TEST(parse_us_eastern_with_dst); + RUN_TEST(parse_us_central_with_time); + RUN_TEST(parse_europe_berlin); + RUN_TEST(parse_new_zealand); + RUN_TEST(parse_explicit_dst_offset); + + printf("\nAngle-bracket notation (espressif/newlib-esp32#8):\n"); + RUN_TEST(parse_angle_bracket_positive); + RUN_TEST(parse_angle_bracket_negative); + RUN_TEST(parse_angle_bracket_with_dst); + RUN_TEST(parse_angle_bracket_named); + RUN_TEST(parse_angle_bracket_with_minutes); + + printf("\nHalf-hour and unusual offsets:\n"); + RUN_TEST(parse_offset_with_minutes_india); + RUN_TEST(parse_offset_with_minutes_nepal); + RUN_TEST(parse_offset_with_seconds); + RUN_TEST(parse_chatham_islands); + + printf("\nInvalid input handling:\n"); + RUN_TEST(parse_empty_string_fails); + RUN_TEST(parse_null_fails); + RUN_TEST(parse_short_name_fails); + RUN_TEST(parse_missing_offset_fails); + RUN_TEST(parse_unterminated_bracket_fails); + + printf("\nJ-format and plain day number:\n"); + RUN_TEST(parse_j_format_basic); + RUN_TEST(parse_j_format_with_time); + RUN_TEST(parse_plain_day_number); + RUN_TEST(parse_plain_day_number_with_time); + RUN_TEST(j_format_invalid_day_zero); + RUN_TEST(j_format_invalid_day_366); + RUN_TEST(plain_day_invalid_day_366); + + printf("\nJulian day to month/day conversion:\n"); + RUN_TEST(julian_day_60_is_march_1); + RUN_TEST(julian_day_1_is_jan_1); + RUN_TEST(julian_day_365_is_dec_31); + RUN_TEST(day_of_year_59_differs_by_leap); + RUN_TEST(day_of_year_0_is_jan_1); + + printf("\nDay of week calculation:\n"); + RUN_TEST(day_of_week_known_dates); + RUN_TEST(leap_year_detection); + RUN_TEST(days_in_month_regular); + RUN_TEST(days_in_month_leap_year); + + printf("\nDST transition calculation:\n"); + RUN_TEST(dst_start_us_eastern_2026); + RUN_TEST(dst_end_us_eastern_2026); + RUN_TEST(last_sunday_of_march_2026); + RUN_TEST(last_sunday_of_october_2026); + RUN_TEST(first_sunday_of_april_2026); + + printf("\nis_in_dst tests:\n"); + RUN_TEST(is_in_dst_us_eastern_summer); + RUN_TEST(is_in_dst_us_eastern_winter); + RUN_TEST(is_in_dst_no_dst_timezone); + RUN_TEST(southern_hemisphere_dst_summer); + RUN_TEST(southern_hemisphere_dst_winter); + + printf("\nepoch_to_local_tm tests:\n"); + RUN_TEST(epoch_to_local_basic); + RUN_TEST(epoch_to_local_with_offset); + RUN_TEST(epoch_to_local_dst_transition); + + printf("\nDST boundary edge cases:\n"); + RUN_TEST(dst_boundary_just_before_spring_forward); + RUN_TEST(dst_boundary_just_before_fall_back); + + printf("\nVerification against libc:\n"); + RUN_TEST(verify_us_eastern_multiple_epochs); + RUN_TEST(verify_us_central_multiple_epochs); + RUN_TEST(verify_europe_berlin_multiple_epochs); + RUN_TEST(verify_angle_bracket_notation); + RUN_TEST(verify_india_half_hour); + RUN_TEST(verify_new_zealand_southern_hemisphere); + RUN_TEST(verify_australia_sydney); + + printf("\n==========================\n"); + printf("All tests passed!\n"); + + return 0; +} From d37c37ef628b2d9af78bb64bcaa8fe3ca0b9cf42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:19:00 -0600 Subject: [PATCH 02/61] tweak --- esphome/components/time/real_time_clock.cpp | 8 ++++++-- esphome/components/time/real_time_clock.h | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 8a78186178..316600d2e4 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -90,8 +90,12 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { #ifdef USE_TIME_TIMEZONE void RealTimeClock::apply_timezone_() { - setenv("TZ", this->timezone_.c_str(), 1); - tzset(); + // Parse the POSIX TZ string using our custom parser to avoid pulling in scanf (~7.6KB) + if (!parse_posix_tz(this->timezone_.c_str(), this->parsed_tz_)) { + ESP_LOGW(TAG, "Failed to parse timezone: %s", this->timezone_.c_str()); + // Reset to UTC on parse failure + this->parsed_tz_ = ParsedTimezone{}; + } } #endif diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 70469e11b0..f1f1fcfa8a 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -6,6 +6,9 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/time.h" +#ifdef USE_TIME_TIMEZONE +#include "esphome/core/posix_tz.h" +#endif namespace esphome::time { @@ -39,7 +42,19 @@ class RealTimeClock : public PollingComponent { #endif /// Get the time in the currently defined timezone. - ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); } + ESPTime now() { +#ifdef USE_TIME_TIMEZONE + time_t epoch = this->timestamp_now(); + struct tm local_tm; + if (epoch_to_local_tm(epoch, this->parsed_tz_, &local_tm)) { + return ESPTime::from_c_tm(&local_tm, epoch); + } + // Fallback to UTC if parsing failed + return ESPTime::from_epoch_utc(epoch); +#else + return ESPTime::from_epoch_local(this->timestamp_now()); +#endif + } /// Get the time without any time zone or DST corrections. ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } @@ -59,6 +74,7 @@ class RealTimeClock : public PollingComponent { #ifdef USE_TIME_TIMEZONE std::string timezone_{}; + ParsedTimezone parsed_tz_{}; void apply_timezone_(); #endif From d45a20af83e253a5d2943991480485deb389e5f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:25:46 -0600 Subject: [PATCH 03/61] tweak --- esphome/components/time/real_time_clock.cpp | 27 ++++++++++++++------- esphome/components/time/real_time_clock.h | 24 ++++++------------ 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 316600d2e4..e04bc3b3d5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -25,7 +25,21 @@ RealTimeClock::RealTimeClock() = default; void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE - ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str()); + int std_hours = -this->parsed_tz_.std_offset_seconds / 3600; + int std_mins = abs(this->parsed_tz_.std_offset_seconds % 3600) / 60; + if (std_mins == 0) { + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d", std_hours); + } else { + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); + } + if (this->parsed_tz_.has_dst) { + int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; + ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, + this->parsed_tz_.dst_start.month, this->parsed_tz_.dst_start.week, + this->parsed_tz_.dst_start.day_of_week, this->parsed_tz_.dst_start.time_seconds / 3600, + this->parsed_tz_.dst_end.month, this->parsed_tz_.dst_end.week, this->parsed_tz_.dst_end.day_of_week, + this->parsed_tz_.dst_end.time_seconds / 3600); + } #endif auto time = this->now(); ESP_LOGCONFIG(TAG, "Current time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour, @@ -72,11 +86,6 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { ret = settimeofday(&timev, nullptr); } -#ifdef USE_TIME_TIMEZONE - // Move timezone back to local timezone. - this->apply_timezone_(); -#endif - if (ret != 0) { ESP_LOGW(TAG, "setimeofday() failed with code %d", ret); } @@ -89,10 +98,10 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { } #ifdef USE_TIME_TIMEZONE -void RealTimeClock::apply_timezone_() { +void RealTimeClock::apply_timezone_(const char *tz) { // Parse the POSIX TZ string using our custom parser to avoid pulling in scanf (~7.6KB) - if (!parse_posix_tz(this->timezone_.c_str(), this->parsed_tz_)) { - ESP_LOGW(TAG, "Failed to parse timezone: %s", this->timezone_.c_str()); + if (!parse_posix_tz(tz, this->parsed_tz_)) { + ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); // Reset to UTC on parse failure this->parsed_tz_ = ParsedTimezone{}; } diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f1f1fcfa8a..055fa7f668 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -23,22 +23,15 @@ class RealTimeClock : public PollingComponent { explicit RealTimeClock(); #ifdef USE_TIME_TIMEZONE - /// Set the time zone. - void set_timezone(const std::string &tz) { - this->timezone_ = tz; - this->apply_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 raw buffer, only if it differs from the current one. - void set_timezone(const char *tz, size_t len) { - if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) { - this->timezone_.assign(tz, len); - this->apply_timezone_(); - } - } + /// Set the time zone from a null-terminated string with known length. + /// The length parameter is ignored since our parser uses null-terminated strings. + void set_timezone(const char *tz, size_t /*len*/) { this->apply_timezone_(tz); } - /// Get the time zone currently in use. - std::string get_timezone() { return this->timezone_; } + /// 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. @@ -73,9 +66,8 @@ class RealTimeClock : public PollingComponent { void synchronize_epoch_(uint32_t epoch); #ifdef USE_TIME_TIMEZONE - std::string timezone_{}; ParsedTimezone parsed_tz_{}; - void apply_timezone_(); + void apply_timezone_(const char *tz); #endif CallbackManager time_sync_callback_; From 47f029b7135fa940037bd2b103367d856258290e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:38:59 -0600 Subject: [PATCH 04/61] cover --- tests/components/time/posix_tz_parser.cpp | 678 +++++++++++++++++ tests/unit_tests/test_posix_tz_parser.cpp | 884 ---------------------- 2 files changed, 678 insertions(+), 884 deletions(-) create mode 100644 tests/components/time/posix_tz_parser.cpp delete mode 100644 tests/unit_tests/test_posix_tz_parser.cpp diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp new file mode 100644 index 0000000000..e977ca53e9 --- /dev/null +++ b/tests/components/time/posix_tz_parser.cpp @@ -0,0 +1,678 @@ +// Tests for the POSIX TZ parser implementation +// This verifies our custom parser produces identical results to libc's +// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). + +#include +#include +#include +#include "esphome/core/posix_tz.h" + +namespace esphome::time::testing { + +// ============================================================================ +// Basic TZ string parsing tests +// ============================================================================ + +TEST(PosixTzParser, ParseSimpleOffsetEST5) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); // +5 hours (west of UTC) + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseNegativeOffsetCET) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); // -1 hour (east of UTC) + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseExplicitPositiveOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("TEST+5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseZeroOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 0); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseUSEasternWithDST) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 2); + EXPECT_EQ(tz.dst_start.day_of_week, 0); // Sunday + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // Default 2:00 AM + EXPECT_EQ(tz.dst_end.month, 11); + EXPECT_EQ(tz.dst_end.week, 1); + EXPECT_EQ(tz.dst_end.day_of_week, 0); +} + +TEST(PosixTzParser, ParseUSCentralWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); + EXPECT_EQ(tz.std_offset_seconds, 6 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // 2:00 AM + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParseEuropeBerlin) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -2 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 5); // Last week + EXPECT_EQ(tz.dst_end.month, 10); + EXPECT_EQ(tz.dst_end.week, 5); // Last week + EXPECT_EQ(tz.dst_end.time_seconds, 3 * 3600); // 3:00 AM +} + +TEST(PosixTzParser, ParseNewZealand) { + ParsedTimezone tz; + // Southern hemisphere - DST starts in Sept, ends in April + ASSERT_TRUE(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -12 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -13 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 9); // September + EXPECT_EQ(tz.dst_end.month, 4); // April +} + +TEST(PosixTzParser, ParseExplicitDstOffset) { + ParsedTimezone tz; + // Some places have non-standard DST offsets + ASSERT_TRUE(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); + EXPECT_TRUE(tz.has_dst); +} + +// ============================================================================ +// Angle-bracket notation tests (espressif/newlib-esp32#8) +// ============================================================================ + +TEST(PosixTzParser, ParseAngleBracketPositive) { + // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+07>-7", tz)); + EXPECT_EQ(tz.std_offset_seconds, -7 * 3600); // -7 = 7 hours east of UTC + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketNegative) { + // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<-03>3", tz)); + EXPECT_EQ(tz.std_offset_seconds, 3 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketWithDST) { + // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -11 * 3600); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 10); + EXPECT_EQ(tz.dst_end.month, 4); +} + +TEST(PosixTzParser, ParseAngleBracketNamed) { + // -10 (Australian Eastern Standard Time) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("-10", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketWithMinutes) { + // <+0545>-5:45 (Nepal) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+0545>-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +// ============================================================================ +// Half-hour and unusual offset tests +// ============================================================================ + +TEST(PosixTzParser, ParseOffsetWithMinutesIndia) { + ParsedTimezone tz; + // India: UTC+5:30 + ASSERT_TRUE(parse_posix_tz("IST-5:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 30 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseOffsetWithMinutesNepal) { + ParsedTimezone tz; + // Nepal: UTC+5:45 + ASSERT_TRUE(parse_posix_tz("NPT-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseOffsetWithSeconds) { + ParsedTimezone tz; + // Unusual but valid: offset with seconds + ASSERT_TRUE(parse_posix_tz("TEST-1:30:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(1 * 3600 + 30 * 60 + 30)); +} + +TEST(PosixTzParser, ParseChathamIslands) { + // Chatham Islands: UTC+12:45 with DST + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(12 * 3600 + 45 * 60)); + EXPECT_EQ(tz.dst_offset_seconds, -(13 * 3600 + 45 * 60)); + EXPECT_TRUE(tz.has_dst); +} + +// ============================================================================ +// Invalid input tests +// ============================================================================ + +TEST(PosixTzParser, ParseEmptyStringFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("", tz)); +} + +TEST(PosixTzParser, ParseNullFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz(nullptr, tz)); +} + +TEST(PosixTzParser, ParseShortNameFails) { + ParsedTimezone tz; + // TZ name must be at least 3 characters + EXPECT_FALSE(parse_posix_tz("AB5", tz)); +} + +TEST(PosixTzParser, ParseMissingOffsetFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("EST", tz)); +} + +TEST(PosixTzParser, ParseUnterminatedBracketFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("<+07-7", tz)); // Missing closing > +} + +// ============================================================================ +// J-format and plain day number tests +// ============================================================================ + +TEST(PosixTzParser, ParseJFormatBasic) { + ParsedTimezone tz; + // J format: Julian day 1-365, not counting Feb 29 + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60,J305", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_start.day, 60); // March 1 + EXPECT_EQ(tz.dst_end.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_end.day, 305); // November 1 +} + +TEST(PosixTzParser, ParseJFormatWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); + EXPECT_EQ(tz.dst_start.day, 60); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); + EXPECT_EQ(tz.dst_end.day, 305); + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParsePlainDayNumber) { + ParsedTimezone tz; + // Plain format: day 0-365, counting Feb 29 in leap years + ASSERT_TRUE(parse_posix_tz("EST5EDT,59,304", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_end.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_end.day, 304); +} + +TEST(PosixTzParser, JFormatInvalidDayZero) { + ParsedTimezone tz; + // J format day must be 1-365, not 0 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J0,J305", tz)); +} + +TEST(PosixTzParser, JFormatInvalidDay366) { + ParsedTimezone tz; + // J format day must be 1-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J366,J305", tz)); +} + +TEST(PosixTzParser, ParsePlainDayNumberWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_start.time_seconds, 3 * 3600); + EXPECT_EQ(tz.dst_end.day, 304); + EXPECT_EQ(tz.dst_end.time_seconds, 1 * 3600 + 30 * 60); +} + +TEST(PosixTzParser, PlainDayInvalidDay366) { + ParsedTimezone tz; + // Plain format day must be 0-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,366,304", tz)); +} + +// ============================================================================ +// Helper function tests +// ============================================================================ + +TEST(PosixTzParser, JulianDay60IsMarch1) { + // J60 is always March 1, regardless of leap year + int month, day; + internal::julian_to_month_day(60, 2024, month, day); // Leap year + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); + internal::julian_to_month_day(60, 2025, month, day); // Non-leap year + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DayOfYear59DiffersByLeap) { + int month, day; + // Day 59 in leap year is Feb 29 + internal::day_of_year_to_month_day(59, 2024, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 29); + // Day 59 in non-leap year is March 1 + internal::day_of_year_to_month_day(59, 2025, month, day); + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DayOfWeekKnownDates) { + // January 1, 1970 was Thursday (4) + EXPECT_EQ(internal::day_of_week(1970, 1, 1), 4); + // January 1, 2000 was Saturday (6) + EXPECT_EQ(internal::day_of_week(2000, 1, 1), 6); + // March 8, 2026 is Sunday (0) - US DST start + EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0); +} + +TEST(PosixTzParser, LeapYearDetection) { + EXPECT_FALSE(internal::is_leap_year(1900)); // Divisible by 100 but not 400 + EXPECT_TRUE(internal::is_leap_year(2000)); // Divisible by 400 + EXPECT_TRUE(internal::is_leap_year(2024)); // Divisible by 4 + EXPECT_FALSE(internal::is_leap_year(2025)); // Not divisible by 4 +} + +TEST(PosixTzParser, JulianDay1IsJan1) { + int month, day; + internal::julian_to_month_day(1, 2025, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, JulianDay365IsDec31) { + int month, day; + internal::julian_to_month_day(365, 2025, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, DayOfYear0IsJan1) { + int month, day; + internal::day_of_year_to_month_day(0, 2025, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DaysInMonthRegular) { + EXPECT_EQ(internal::days_in_month(2025, 1), 31); + EXPECT_EQ(internal::days_in_month(2025, 2), 28); + EXPECT_EQ(internal::days_in_month(2025, 4), 30); + EXPECT_EQ(internal::days_in_month(2025, 12), 31); +} + +TEST(PosixTzParser, DaysInMonthLeapYear) { + EXPECT_EQ(internal::days_in_month(2024, 2), 29); + EXPECT_EQ(internal::days_in_month(2025, 2), 28); +} + +// ============================================================================ +// DST transition calculation tests +// ============================================================================ + +TEST(PosixTzParser, DstStartUSEastern2026) { + // March 8, 2026 is 2nd Sunday of March + 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); + struct tm tm; + internal::epoch_to_tm_utc(dst_start, &tm); + + // At 2:00 AM EST (UTC-5), so 7:00 AM UTC + EXPECT_EQ(tm.tm_year + 1900, 2026); + EXPECT_EQ(tm.tm_mon + 1, 3); // March + EXPECT_EQ(tm.tm_mday, 8); // 8th + EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST +} + +TEST(PosixTzParser, DstEndUSEastern2026) { + // November 1, 2026 is 1st Sunday of November + 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); + struct tm tm; + internal::epoch_to_tm_utc(dst_end, &tm); + + // At 2:00 AM EDT (UTC-4), so 6:00 AM UTC + EXPECT_EQ(tm.tm_year + 1900, 2026); + EXPECT_EQ(tm.tm_mon + 1, 11); // November + EXPECT_EQ(tm.tm_mday, 1); // 1st + EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT +} + +TEST(PosixTzParser, LastSundayOfMarch2026) { + // Europe: M3.5.0 = last Sunday of March = March 29, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 29); + EXPECT_EQ(tm.tm_wday, 0); // Sunday +} + +TEST(PosixTzParser, LastSundayOfOctober2026) { + // Europe: M10.5.0 = last Sunday of October = October 25, 2026 + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 10; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 3 * 3600; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 25); + EXPECT_EQ(tm.tm_wday, 0); // Sunday +} + +TEST(PosixTzParser, FirstSundayOfApril2026) { + // April 5, 2026 is 1st Sunday + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 4; + rule.week = 1; + rule.day_of_week = 0; + rule.time_seconds = 0; + time_t transition = calculate_dst_transition(2026, rule, 0); + struct tm tm; + internal::epoch_to_tm_utc(transition, &tm); + EXPECT_EQ(tm.tm_mday, 5); + EXPECT_EQ(tm.tm_wday, 0); +} + +// ============================================================================ +// DST detection tests +// ============================================================================ + +TEST(PosixTzParser, IsInDstUSEasternSummer) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 12:00 UTC - definitely in DST + struct tm july4 {}; + july4.tm_hour = 12; + july4.tm_mday = 4; + july4.tm_mon = 6; + july4.tm_year = 126; + time_t summer = internal::tm_to_epoch_utc(&july4); + EXPECT_TRUE(is_in_dst(summer, tz)); +} + +TEST(PosixTzParser, IsInDstUSEasternWinter) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // January 15, 2026 12:00 UTC - definitely not in DST + struct tm jan15 {}; + jan15.tm_hour = 12; + jan15.tm_mday = 15; + jan15.tm_mon = 0; + jan15.tm_year = 126; + time_t winter = internal::tm_to_epoch_utc(&jan15); + EXPECT_FALSE(is_in_dst(winter, tz)); +} + +TEST(PosixTzParser, IsInDstNoDstTimezone) { + ParsedTimezone tz; + parse_posix_tz("IST-5:30", tz); + + struct tm anytime {}; + anytime.tm_hour = 12; + anytime.tm_mday = 15; + anytime.tm_mon = 6; + anytime.tm_year = 126; + time_t epoch = internal::tm_to_epoch_utc(&anytime); + EXPECT_FALSE(is_in_dst(epoch, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstSummer) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // December 15, 2025 12:00 UTC - summer in NZ, should be in DST + struct tm dec15 {}; + dec15.tm_hour = 12; + dec15.tm_mday = 15; + dec15.tm_mon = 11; + dec15.tm_year = 125; + time_t nz_summer = internal::tm_to_epoch_utc(&dec15); + EXPECT_TRUE(is_in_dst(nz_summer, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstWinter) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST + struct tm july15 {}; + july15.tm_hour = 12; + july15.tm_mday = 15; + july15.tm_mon = 6; + july15.tm_year = 126; + time_t nz_winter = internal::tm_to_epoch_utc(&july15); + EXPECT_FALSE(is_in_dst(nz_winter, tz)); +} + +// ============================================================================ +// epoch_to_local_tm tests +// ============================================================================ + +TEST(PosixTzParser, EpochToLocalBasic) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 70); + EXPECT_EQ(local.tm_mon, 0); + EXPECT_EQ(local.tm_mday, 1); + EXPECT_EQ(local.tm_hour, 0); +} + +TEST(PosixTzParser, EpochToLocalWithOffset) { + ParsedTimezone tz; + parse_posix_tz("EST5", tz); // UTC-5 + + // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST + struct tm utc_tm {}; + utc_tm.tm_hour = 5; + utc_tm.tm_mday = 1; + utc_tm.tm_mon = 0; + utc_tm.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); + + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 0); // Midnight EST + EXPECT_EQ(local.tm_mday, 1); + EXPECT_EQ(local.tm_isdst, 0); +} + +TEST(PosixTzParser, EpochToLocalDstTransition) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 16:00 UTC = 12:00 EDT (noon) + struct tm july4_utc {}; + july4_utc.tm_hour = 16; + july4_utc.tm_mday = 4; + july4_utc.tm_mon = 6; + july4_utc.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); + + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 12); // Noon EDT + EXPECT_EQ(local.tm_isdst, 1); +} + +// ============================================================================ +// Verification against libc +// ============================================================================ + +class LibcVerificationTest : public ::testing::TestWithParam> {}; + +TEST_P(LibcVerificationTest, MatchesLibc) { + auto [tz_str, epoch] = GetParam(); + + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + + // Our implementation + struct tm our_tm; + epoch_to_local_tm(epoch, tz, &our_tm); + + // libc implementation + setenv("TZ", tz_str, 1); + tzset(); + struct tm *libc_tm = localtime(&epoch); + + EXPECT_EQ(our_tm.tm_year, libc_tm->tm_year); + EXPECT_EQ(our_tm.tm_mon, libc_tm->tm_mon); + EXPECT_EQ(our_tm.tm_mday, libc_tm->tm_mday); + EXPECT_EQ(our_tm.tm_hour, libc_tm->tm_hour); + EXPECT_EQ(our_tm.tm_min, libc_tm->tm_min); + EXPECT_EQ(our_tm.tm_sec, libc_tm->tm_sec); + EXPECT_EQ(our_tm.tm_isdst, libc_tm->tm_isdst); +} + +INSTANTIATE_TEST_SUITE_P(USEastern, LibcVerificationTest, + ::testing::Values(std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AngleBracket, LibcVerificationTest, + ::testing::Values(std::make_tuple("<+07>-7", 1704067200), + std::make_tuple("<+07>-7", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(India, LibcVerificationTest, + ::testing::Values(std::make_tuple("IST-5:30", 1704067200), + std::make_tuple("IST-5:30", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(NewZealand, LibcVerificationTest, + ::testing::Values(std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1704067200), + std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(USCentral, LibcVerificationTest, + ::testing::Values(std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(EuropeBerlin, LibcVerificationTest, + ::testing::Values(std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1704067200), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1720000000), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest, + ::testing::Values(std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1704067200), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); + +// ============================================================================ +// DST boundary edge cases +// ============================================================================ + +TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { + // Test 1 second before DST starts + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 6; + before.tm_mday = 8; + before.tm_mon = 2; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + EXPECT_FALSE(is_in_dst(before_epoch, tz)); + + // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) + struct tm after {}; + after.tm_hour = 7; + after.tm_mday = 8; + after.tm_mon = 2; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + EXPECT_TRUE(is_in_dst(after_epoch, tz)); +} + +TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { + // Test 1 second before DST ends + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 5; + before.tm_mday = 1; + before.tm_mon = 10; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + EXPECT_TRUE(is_in_dst(before_epoch, tz)); + + // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) + struct tm after {}; + after.tm_hour = 6; + after.tm_mday = 1; + after.tm_mon = 10; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + EXPECT_FALSE(is_in_dst(after_epoch, tz)); +} + +} // namespace esphome::time::testing diff --git a/tests/unit_tests/test_posix_tz_parser.cpp b/tests/unit_tests/test_posix_tz_parser.cpp deleted file mode 100644 index dbc0ecf380..0000000000 --- a/tests/unit_tests/test_posix_tz_parser.cpp +++ /dev/null @@ -1,884 +0,0 @@ -// Test POSIX TZ parser implementation -// Compile with: g++ -std=gnu++20 -I../../esphome/core -o test_posix_tz_parser test_posix_tz_parser.cpp -// ../../esphome/core/posix_tz.cpp && ./test_posix_tz_parser -// -// This test verifies our custom POSIX TZ parser produces identical results to libc's -// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). -// -// Key test cases include: -// - Angle-bracket timezone notation (<+07>-7) - see espressif/newlib-esp32#8 -// - Half-hour offsets (IST-5:30) -// - Southern hemisphere DST (start month > end month) -// - DST transition boundary conditions - -#include -#include -#include -#include -#include - -// Include the implementation directly for standalone compilation -#include "../../esphome/core/posix_tz.h" -#include "../../esphome/core/posix_tz.cpp" - -using namespace esphome; - -#define TEST(name) static void test_##name() -#define RUN_TEST(name) \ - do { \ - printf(" " #name "..."); \ - fflush(stdout); \ - test_##name(); \ - printf(" OK\n"); \ - } while (0) - -// ============================================================================ -// Basic TZ string parsing tests -// ============================================================================ - -TEST(parse_simple_offset_est5) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5", tz)); - assert(tz.std_offset_seconds == 5 * 3600); // +5 hours (west of UTC) - assert(!tz.has_dst); -} - -TEST(parse_negative_offset_cet) { - ParsedTimezone tz; - assert(parse_posix_tz("CET-1", tz)); - assert(tz.std_offset_seconds == -1 * 3600); // -1 hour (east of UTC) - assert(!tz.has_dst); -} - -TEST(parse_explicit_positive_offset) { - ParsedTimezone tz; - assert(parse_posix_tz("TEST+5", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_zero_offset) { - ParsedTimezone tz; - assert(parse_posix_tz("UTC0", tz)); - assert(tz.std_offset_seconds == 0); - assert(!tz.has_dst); -} - -TEST(parse_us_eastern_with_dst) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(tz.dst_offset_seconds == 4 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 3); - assert(tz.dst_start.week == 2); - assert(tz.dst_start.day_of_week == 0); // Sunday - assert(tz.dst_start.time_seconds == 2 * 3600); // Default 2:00 AM - assert(tz.dst_end.month == 11); - assert(tz.dst_end.week == 1); - assert(tz.dst_end.day_of_week == 0); -} - -TEST(parse_us_central_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); - assert(tz.std_offset_seconds == 6 * 3600); - assert(tz.dst_offset_seconds == 5 * 3600); - assert(tz.dst_start.time_seconds == 2 * 3600); // 2:00 AM - assert(tz.dst_end.time_seconds == 2 * 3600); -} - -TEST(parse_europe_berlin) { - ParsedTimezone tz; - assert(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); - assert(tz.std_offset_seconds == -1 * 3600); - assert(tz.dst_offset_seconds == -2 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 3); - assert(tz.dst_start.week == 5); // Last week - assert(tz.dst_end.month == 10); - assert(tz.dst_end.week == 5); // Last week - assert(tz.dst_end.time_seconds == 3 * 3600); // 3:00 AM -} - -TEST(parse_new_zealand) { - ParsedTimezone tz; - // Southern hemisphere - DST starts in Sept, ends in April - assert(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); - assert(tz.std_offset_seconds == -12 * 3600); - assert(tz.dst_offset_seconds == -13 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 9); // September - assert(tz.dst_end.month == 4); // April -} - -TEST(parse_explicit_dst_offset) { - ParsedTimezone tz; - // Some places have non-standard DST offsets - assert(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(tz.dst_offset_seconds == 4 * 3600); - assert(tz.has_dst); -} - -// ============================================================================ -// Angle-bracket notation tests (espressif/newlib-esp32#8) -// ============================================================================ - -TEST(parse_angle_bracket_positive) { - // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) - ParsedTimezone tz; - assert(parse_posix_tz("<+07>-7", tz)); - assert(tz.std_offset_seconds == -7 * 3600); // -7 = 7 hours east of UTC - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_negative) { - // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) - ParsedTimezone tz; - assert(parse_posix_tz("<-03>3", tz)); - assert(tz.std_offset_seconds == 3 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_with_dst) { - // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) - ParsedTimezone tz; - assert(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); - assert(tz.std_offset_seconds == -10 * 3600); - assert(tz.dst_offset_seconds == -11 * 3600); - assert(tz.has_dst); - assert(tz.dst_start.month == 10); - assert(tz.dst_end.month == 4); -} - -TEST(parse_angle_bracket_named) { - // -10 (Australian Eastern Standard Time) - ParsedTimezone tz; - assert(parse_posix_tz("-10", tz)); - assert(tz.std_offset_seconds == -10 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_with_minutes) { - // <+0545>-5:45 (Nepal) - ParsedTimezone tz; - assert(parse_posix_tz("<+0545>-5:45", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); - assert(!tz.has_dst); -} - -// ============================================================================ -// Half-hour and unusual offset tests -// ============================================================================ - -TEST(parse_offset_with_minutes_india) { - ParsedTimezone tz; - // India: UTC+5:30 - assert(parse_posix_tz("IST-5:30", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 30 * 60)); - assert(!tz.has_dst); -} - -TEST(parse_offset_with_minutes_nepal) { - ParsedTimezone tz; - // Nepal: UTC+5:45 - assert(parse_posix_tz("NPT-5:45", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); - assert(!tz.has_dst); -} - -TEST(parse_offset_with_seconds) { - ParsedTimezone tz; - // Unusual but valid: offset with seconds - assert(parse_posix_tz("TEST-1:30:30", tz)); - assert(tz.std_offset_seconds == -(1 * 3600 + 30 * 60 + 30)); -} - -TEST(parse_chatham_islands) { - // Chatham Islands: UTC+12:45 with DST - ParsedTimezone tz; - assert(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); - assert(tz.std_offset_seconds == -(12 * 3600 + 45 * 60)); - assert(tz.dst_offset_seconds == -(13 * 3600 + 45 * 60)); - assert(tz.has_dst); -} - -// ============================================================================ -// Invalid input tests -// ============================================================================ - -TEST(parse_empty_string_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("", tz)); -} - -TEST(parse_null_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz(nullptr, tz)); -} - -TEST(parse_short_name_fails) { - ParsedTimezone tz; - // TZ name must be at least 3 characters - assert(!parse_posix_tz("AB5", tz)); -} - -TEST(parse_missing_offset_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("EST", tz)); -} - -TEST(parse_unterminated_bracket_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("<+07-7", tz)); // Missing closing > -} - -// ============================================================================ -// J-format and plain day number tests -// ============================================================================ - -TEST(parse_j_format_basic) { - ParsedTimezone tz; - // J format: Julian day 1-365, not counting Feb 29 - assert(parse_posix_tz("EST5EDT,J60,J305", tz)); - assert(tz.has_dst); - assert(tz.dst_start.type == DSTRuleType::JULIAN_NO_LEAP); - assert(tz.dst_start.day == 60); // March 1 - assert(tz.dst_end.type == DSTRuleType::JULIAN_NO_LEAP); - assert(tz.dst_end.day == 305); // November 1 -} - -TEST(parse_j_format_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); - assert(tz.dst_start.day == 60); - assert(tz.dst_start.time_seconds == 2 * 3600); - assert(tz.dst_end.day == 305); - assert(tz.dst_end.time_seconds == 2 * 3600); -} - -TEST(parse_plain_day_number) { - ParsedTimezone tz; - // Plain format: day 0-365, counting Feb 29 in leap years - assert(parse_posix_tz("EST5EDT,59,304", tz)); - assert(tz.has_dst); - assert(tz.dst_start.type == DSTRuleType::DAY_OF_YEAR); - assert(tz.dst_start.day == 59); // Feb 29 or March 1 depending on leap year - assert(tz.dst_end.type == DSTRuleType::DAY_OF_YEAR); - assert(tz.dst_end.day == 304); -} - -TEST(parse_plain_day_number_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); - assert(tz.dst_start.day == 59); - assert(tz.dst_start.time_seconds == 3 * 3600); - assert(tz.dst_end.day == 304); - assert(tz.dst_end.time_seconds == 1 * 3600 + 30 * 60); -} - -TEST(j_format_invalid_day_zero) { - ParsedTimezone tz; - // J format day must be 1-365, not 0 - assert(!parse_posix_tz("EST5EDT,J0,J305", tz)); -} - -TEST(j_format_invalid_day_366) { - ParsedTimezone tz; - // J format day must be 1-365 - assert(!parse_posix_tz("EST5EDT,J366,J305", tz)); -} - -TEST(plain_day_invalid_day_366) { - ParsedTimezone tz; - // Plain format day must be 0-365 - assert(!parse_posix_tz("EST5EDT,366,304", tz)); -} - -// ============================================================================ -// Julian day to month/day conversion tests -// ============================================================================ - -TEST(julian_day_60_is_march_1) { - // J60 is always March 1, regardless of leap year - int month, day; - internal::julian_to_month_day(60, 2024, month, day); // Leap year - assert(month == 3 && day == 1); - internal::julian_to_month_day(60, 2025, month, day); // Non-leap year - assert(month == 3 && day == 1); -} - -TEST(julian_day_1_is_jan_1) { - int month, day; - internal::julian_to_month_day(1, 2025, month, day); - assert(month == 1 && day == 1); -} - -TEST(julian_day_365_is_dec_31) { - int month, day; - internal::julian_to_month_day(365, 2025, month, day); - assert(month == 12 && day == 31); -} - -TEST(day_of_year_59_differs_by_leap) { - int month, day; - // Day 59 in leap year is Feb 29 - internal::day_of_year_to_month_day(59, 2024, month, day); - assert(month == 2 && day == 29); - // Day 59 in non-leap year is March 1 - internal::day_of_year_to_month_day(59, 2025, month, day); - assert(month == 3 && day == 1); -} - -TEST(day_of_year_0_is_jan_1) { - int month, day; - internal::day_of_year_to_month_day(0, 2025, month, day); - assert(month == 1 && day == 1); -} - -// ============================================================================ -// Day of week calculation tests -// ============================================================================ - -TEST(day_of_week_known_dates) { - // January 1, 1970 was Thursday (4) - assert(internal::day_of_week(1970, 1, 1) == 4); - - // July 4, 1776 was Thursday (4) - assert(internal::day_of_week(1776, 7, 4) == 4); - - // January 1, 2000 was Saturday (6) - assert(internal::day_of_week(2000, 1, 1) == 6); - - // September 11, 2001 was Tuesday (2) - assert(internal::day_of_week(2001, 9, 11) == 2); - - // March 8, 2026 is Sunday (0) - US DST start - assert(internal::day_of_week(2026, 3, 8) == 0); - - // November 1, 2026 is Sunday (0) - US DST end - assert(internal::day_of_week(2026, 11, 1) == 0); -} - -TEST(leap_year_detection) { - assert(!internal::is_leap_year(1900)); // Divisible by 100 but not 400 - assert(internal::is_leap_year(2000)); // Divisible by 400 - assert(internal::is_leap_year(2024)); // Divisible by 4 - assert(!internal::is_leap_year(2025)); // Not divisible by 4 - assert(internal::is_leap_year(2028)); -} - -TEST(days_in_month_regular) { - assert(internal::days_in_month(2025, 1) == 31); - assert(internal::days_in_month(2025, 2) == 28); - assert(internal::days_in_month(2025, 4) == 30); - assert(internal::days_in_month(2025, 12) == 31); -} - -TEST(days_in_month_leap_year) { - assert(internal::days_in_month(2024, 2) == 29); - assert(internal::days_in_month(2025, 2) == 28); -} - -// ============================================================================ -// DST transition calculation tests -// ============================================================================ - -TEST(dst_start_us_eastern_2026) { - // March 8, 2026 is 2nd Sunday of March - 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); - struct tm tm; - internal::epoch_to_tm_utc(dst_start, &tm); - - // At 2:00 AM EST (UTC-5), so 7:00 AM UTC - assert(tm.tm_year + 1900 == 2026); - assert(tm.tm_mon + 1 == 3); // March - assert(tm.tm_mday == 8); // 8th - assert(tm.tm_hour == 7); // 7:00 UTC = 2:00 EST -} - -TEST(dst_end_us_eastern_2026) { - // November 1, 2026 is 1st Sunday of November - 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); - struct tm tm; - internal::epoch_to_tm_utc(dst_end, &tm); - - // At 2:00 AM EDT (UTC-4), so 6:00 AM UTC - assert(tm.tm_year + 1900 == 2026); - assert(tm.tm_mon + 1 == 11); // November - assert(tm.tm_mday == 1); // 1st - assert(tm.tm_hour == 6); // 6:00 UTC = 2:00 EDT -} - -TEST(last_sunday_of_march_2026) { - // Europe: M3.5.0 = last Sunday of March = March 29, 2026 - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 3; - rule.week = 5; - rule.day_of_week = 0; - rule.time_seconds = 2 * 3600; - time_t transition = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 29); - assert(tm.tm_wday == 0); // Sunday -} - -TEST(last_sunday_of_october_2026) { - // Europe: M10.5.0 = last Sunday of October = October 25, 2026 - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 10; - rule.week = 5; - rule.day_of_week = 0; - rule.time_seconds = 3 * 3600; - time_t transition = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 25); - assert(tm.tm_wday == 0); // Sunday -} - -TEST(first_sunday_of_april_2026) { - // April 5, 2026 is 1st Sunday - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 4; - rule.week = 1; - rule.day_of_week = 0; - rule.time_seconds = 0; - time_t transition = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 5); - assert(tm.tm_wday == 0); -} - -// ============================================================================ -// is_in_dst tests -// ============================================================================ - -TEST(is_in_dst_us_eastern_summer) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // July 4, 2026 12:00 UTC - definitely in DST - struct tm july4 {}; - july4.tm_sec = 0; - july4.tm_min = 0; - july4.tm_hour = 12; - july4.tm_mday = 4; - july4.tm_mon = 6; - july4.tm_year = 126; - time_t summer = internal::tm_to_epoch_utc(&july4); - assert(is_in_dst(summer, tz) == true); -} - -TEST(is_in_dst_us_eastern_winter) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // January 15, 2026 12:00 UTC - definitely not in DST - struct tm jan15 {}; - jan15.tm_sec = 0; - jan15.tm_min = 0; - jan15.tm_hour = 12; - jan15.tm_mday = 15; - jan15.tm_mon = 0; - jan15.tm_year = 126; - time_t winter = internal::tm_to_epoch_utc(&jan15); - assert(is_in_dst(winter, tz) == false); -} - -TEST(is_in_dst_no_dst_timezone) { - ParsedTimezone tz; - parse_posix_tz("IST-5:30", tz); - - struct tm anytime {}; - anytime.tm_sec = 0; - anytime.tm_min = 0; - anytime.tm_hour = 12; - anytime.tm_mday = 15; - anytime.tm_mon = 6; - anytime.tm_year = 126; - time_t epoch = internal::tm_to_epoch_utc(&anytime); - assert(is_in_dst(epoch, tz) == false); -} - -TEST(southern_hemisphere_dst_summer) { - ParsedTimezone tz; - parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); - - // December 15, 2025 12:00 UTC - summer in NZ, should be in DST - struct tm dec15 {}; - dec15.tm_sec = 0; - dec15.tm_min = 0; - dec15.tm_hour = 12; - dec15.tm_mday = 15; - dec15.tm_mon = 11; - dec15.tm_year = 125; - time_t nz_summer = internal::tm_to_epoch_utc(&dec15); - assert(is_in_dst(nz_summer, tz) == true); -} - -TEST(southern_hemisphere_dst_winter) { - ParsedTimezone tz; - parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); - - // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST - struct tm july15 {}; - july15.tm_sec = 0; - july15.tm_min = 0; - july15.tm_hour = 12; - july15.tm_mday = 15; - july15.tm_mon = 6; - july15.tm_year = 126; - time_t nz_winter = internal::tm_to_epoch_utc(&july15); - assert(is_in_dst(nz_winter, tz) == false); -} - -// ============================================================================ -// epoch_to_local_tm tests -// ============================================================================ - -TEST(epoch_to_local_basic) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); - - time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC - struct tm local; - assert(epoch_to_local_tm(epoch, tz, &local)); - assert(local.tm_year == 70); - assert(local.tm_mon == 0); - assert(local.tm_mday == 1); - assert(local.tm_hour == 0); -} - -TEST(epoch_to_local_with_offset) { - ParsedTimezone tz; - parse_posix_tz("EST5", tz); // UTC-5 - - // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST - struct tm utc_tm {}; - utc_tm.tm_sec = 0; - utc_tm.tm_min = 0; - utc_tm.tm_hour = 5; - utc_tm.tm_mday = 1; - utc_tm.tm_mon = 0; - utc_tm.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); - - struct tm local; - assert(epoch_to_local_tm(utc_epoch, tz, &local)); - assert(local.tm_hour == 0); // Midnight EST - assert(local.tm_mday == 1); - assert(local.tm_isdst == 0); -} - -TEST(epoch_to_local_dst_transition) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // July 4, 2026 16:00 UTC = 12:00 EDT (noon) - struct tm july4_utc {}; - july4_utc.tm_sec = 0; - july4_utc.tm_min = 0; - july4_utc.tm_hour = 16; - july4_utc.tm_mday = 4; - july4_utc.tm_mon = 6; - july4_utc.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); - - struct tm local; - assert(epoch_to_local_tm(utc_epoch, tz, &local)); - assert(local.tm_hour == 12); // Noon EDT - assert(local.tm_isdst == 1); -} - -// ============================================================================ -// Verification against libc (run on desktop only) -// ============================================================================ - -// Helper to compare our implementation against libc -static bool verify_against_libc(const char *tz_str, time_t epoch) { - ParsedTimezone tz; - if (!parse_posix_tz(tz_str, tz)) { - printf("Failed to parse TZ: %s\n", tz_str); - return false; - } - - // Our implementation - struct tm our_tm; - epoch_to_local_tm(epoch, tz, &our_tm); - - // libc implementation - setenv("TZ", tz_str, 1); - tzset(); - struct tm *libc_tm = localtime(&epoch); - - bool match = - (our_tm.tm_year == libc_tm->tm_year && our_tm.tm_mon == libc_tm->tm_mon && our_tm.tm_mday == libc_tm->tm_mday && - our_tm.tm_hour == libc_tm->tm_hour && our_tm.tm_min == libc_tm->tm_min && our_tm.tm_sec == libc_tm->tm_sec && - our_tm.tm_isdst == libc_tm->tm_isdst); - - if (!match) { - printf("\nMismatch for TZ=%s epoch=%ld\n", tz_str, (long) epoch); - printf(" Our: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", our_tm.tm_year + 1900, our_tm.tm_mon + 1, our_tm.tm_mday, - our_tm.tm_hour, our_tm.tm_min, our_tm.tm_sec, our_tm.tm_isdst); - printf(" libc: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", libc_tm->tm_year + 1900, libc_tm->tm_mon + 1, - libc_tm->tm_mday, libc_tm->tm_hour, libc_tm->tm_min, libc_tm->tm_sec, libc_tm->tm_isdst); - } - - return match; -} - -TEST(verify_us_eastern_multiple_epochs) { - const char *tz_str = "EST5EDT,M3.2.0/2,M11.1.0/2"; - // Test various dates throughout the year - time_t epochs[] = { - 1704067200, // Jan 1, 2024 00:00 UTC - 1711900800, // March 31, 2024 12:00 UTC (after DST start) - 1720000000, // July 3, 2024 (summer) - 1730419200, // Nov 1, 2024 00:00 UTC (DST end day) - 1735689600, // Jan 1, 2025 00:00 UTC - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_us_central_multiple_epochs) { - const char *tz_str = "CST6CDT,M3.2.0/2,M11.1.0/2"; - time_t epochs[] = { - 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_europe_berlin_multiple_epochs) { - const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0/3"; - time_t epochs[] = { - 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_angle_bracket_notation) { - // This was the bug in espressif/newlib-esp32#8 - const char *tz_str = "<+07>-7"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_india_half_hour) { - const char *tz_str = "IST-5:30"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_new_zealand_southern_hemisphere) { - const char *tz_str = "NZST-12NZDT,M9.5.0,M4.1.0/3"; - time_t epochs[] = { - 1704067200, // Jan (NZ summer, DST) - 1720000000, // July (NZ winter, no DST) - 1735689600, // Jan (NZ summer, DST) - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_australia_sydney) { - const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0/3"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -// ============================================================================ -// DST boundary edge cases -// ============================================================================ - -TEST(dst_boundary_just_before_spring_forward) { - // Test 1 second before DST starts - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 6; - before.tm_mday = 8; - before.tm_mon = 2; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); - assert(is_in_dst(before_epoch, tz) == false); - - // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) - struct tm after {}; - after.tm_sec = 0; - after.tm_min = 0; - after.tm_hour = 7; - after.tm_mday = 8; - after.tm_mon = 2; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); - assert(is_in_dst(after_epoch, tz) == true); -} - -TEST(dst_boundary_just_before_fall_back) { - // Test 1 second before DST ends - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 5; - before.tm_mday = 1; - before.tm_mon = 10; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); - assert(is_in_dst(before_epoch, tz) == true); - - // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) - struct tm after {}; - after.tm_sec = 0; - after.tm_min = 0; - after.tm_hour = 6; - after.tm_mday = 1; - after.tm_mon = 10; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); - assert(is_in_dst(after_epoch, tz) == false); -} - -// ============================================================================ -// Main -// ============================================================================ - -int main() { - printf("POSIX TZ Parser Unit Tests\n"); - printf("==========================\n\n"); - - printf("Basic TZ string parsing:\n"); - RUN_TEST(parse_simple_offset_est5); - RUN_TEST(parse_negative_offset_cet); - RUN_TEST(parse_explicit_positive_offset); - RUN_TEST(parse_zero_offset); - RUN_TEST(parse_us_eastern_with_dst); - RUN_TEST(parse_us_central_with_time); - RUN_TEST(parse_europe_berlin); - RUN_TEST(parse_new_zealand); - RUN_TEST(parse_explicit_dst_offset); - - printf("\nAngle-bracket notation (espressif/newlib-esp32#8):\n"); - RUN_TEST(parse_angle_bracket_positive); - RUN_TEST(parse_angle_bracket_negative); - RUN_TEST(parse_angle_bracket_with_dst); - RUN_TEST(parse_angle_bracket_named); - RUN_TEST(parse_angle_bracket_with_minutes); - - printf("\nHalf-hour and unusual offsets:\n"); - RUN_TEST(parse_offset_with_minutes_india); - RUN_TEST(parse_offset_with_minutes_nepal); - RUN_TEST(parse_offset_with_seconds); - RUN_TEST(parse_chatham_islands); - - printf("\nInvalid input handling:\n"); - RUN_TEST(parse_empty_string_fails); - RUN_TEST(parse_null_fails); - RUN_TEST(parse_short_name_fails); - RUN_TEST(parse_missing_offset_fails); - RUN_TEST(parse_unterminated_bracket_fails); - - printf("\nJ-format and plain day number:\n"); - RUN_TEST(parse_j_format_basic); - RUN_TEST(parse_j_format_with_time); - RUN_TEST(parse_plain_day_number); - RUN_TEST(parse_plain_day_number_with_time); - RUN_TEST(j_format_invalid_day_zero); - RUN_TEST(j_format_invalid_day_366); - RUN_TEST(plain_day_invalid_day_366); - - printf("\nJulian day to month/day conversion:\n"); - RUN_TEST(julian_day_60_is_march_1); - RUN_TEST(julian_day_1_is_jan_1); - RUN_TEST(julian_day_365_is_dec_31); - RUN_TEST(day_of_year_59_differs_by_leap); - RUN_TEST(day_of_year_0_is_jan_1); - - printf("\nDay of week calculation:\n"); - RUN_TEST(day_of_week_known_dates); - RUN_TEST(leap_year_detection); - RUN_TEST(days_in_month_regular); - RUN_TEST(days_in_month_leap_year); - - printf("\nDST transition calculation:\n"); - RUN_TEST(dst_start_us_eastern_2026); - RUN_TEST(dst_end_us_eastern_2026); - RUN_TEST(last_sunday_of_march_2026); - RUN_TEST(last_sunday_of_october_2026); - RUN_TEST(first_sunday_of_april_2026); - - printf("\nis_in_dst tests:\n"); - RUN_TEST(is_in_dst_us_eastern_summer); - RUN_TEST(is_in_dst_us_eastern_winter); - RUN_TEST(is_in_dst_no_dst_timezone); - RUN_TEST(southern_hemisphere_dst_summer); - RUN_TEST(southern_hemisphere_dst_winter); - - printf("\nepoch_to_local_tm tests:\n"); - RUN_TEST(epoch_to_local_basic); - RUN_TEST(epoch_to_local_with_offset); - RUN_TEST(epoch_to_local_dst_transition); - - printf("\nDST boundary edge cases:\n"); - RUN_TEST(dst_boundary_just_before_spring_forward); - RUN_TEST(dst_boundary_just_before_fall_back); - - printf("\nVerification against libc:\n"); - RUN_TEST(verify_us_eastern_multiple_epochs); - RUN_TEST(verify_us_central_multiple_epochs); - RUN_TEST(verify_europe_berlin_multiple_epochs); - RUN_TEST(verify_angle_bracket_notation); - RUN_TEST(verify_india_half_hour); - RUN_TEST(verify_new_zealand_southern_hemisphere); - RUN_TEST(verify_australia_sydney); - - printf("\n==========================\n"); - printf("All tests passed!\n"); - - return 0; -} From 4cdf0224baf9a18f9cdcd82d19bc4d2d36eb3c18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:48:46 -0600 Subject: [PATCH 05/61] tweak --- esphome/{core => components/time}/posix_tz.cpp | 0 esphome/{core => components/time}/posix_tz.h | 0 esphome/components/time/real_time_clock.h | 2 +- tests/components/time/posix_tz_parser.cpp | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename esphome/{core => components/time}/posix_tz.cpp (100%) rename esphome/{core => components/time}/posix_tz.h (100%) diff --git a/esphome/core/posix_tz.cpp b/esphome/components/time/posix_tz.cpp similarity index 100% rename from esphome/core/posix_tz.cpp rename to esphome/components/time/posix_tz.cpp diff --git a/esphome/core/posix_tz.h b/esphome/components/time/posix_tz.h similarity index 100% rename from esphome/core/posix_tz.h rename to esphome/components/time/posix_tz.h diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 055fa7f668..0f9ec1e993 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/time.h" #ifdef USE_TIME_TIMEZONE -#include "esphome/core/posix_tz.h" +#include "posix_tz.h" #endif namespace esphome::time { diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index e977ca53e9..6c1fea36e5 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -5,7 +5,7 @@ #include #include #include -#include "esphome/core/posix_tz.h" +#include "esphome/components/time/posix_tz.h" namespace esphome::time::testing { From ea83330ab9d9b9f8588155576b5e5a86707d19c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:52:24 -0600 Subject: [PATCH 06/61] tweak --- esphome/components/time/posix_tz.cpp | 115 +++++++-------------------- esphome/components/time/posix_tz.h | 1 - 2 files changed, 27 insertions(+), 89 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 1de9acd308..02f47d3a7d 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -5,6 +5,16 @@ namespace esphome { namespace internal { +// Helper to parse an unsigned integer from string, updating pointer +static uint32_t parse_uint(const char *&p) { + uint32_t value = 0; + while (std::isdigit(static_cast(*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); } int days_in_month(int year, int month) { @@ -125,81 +135,33 @@ int32_t parse_offset(const char *&p) { p++; } - // Parse hours - int hours = 0; - while (*p && std::isdigit(static_cast(*p))) { - hours = hours * 10 + (*p - '0'); - p++; - } - + int hours = parse_uint(p); int minutes = 0; int seconds = 0; - // Optional :mm if (*p == ':') { p++; - while (*p && std::isdigit(static_cast(*p))) { - minutes = minutes * 10 + (*p - '0'); - p++; - } - - // Optional :ss + minutes = parse_uint(p); if (*p == ':') { p++; - while (*p && std::isdigit(static_cast(*p))) { - seconds = seconds * 10 + (*p - '0'); - p++; - } + seconds = parse_uint(p); } } return sign * (hours * 3600 + minutes * 60 + seconds); } -// Helper to parse the optional /time suffix +// 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++; - // Parse time as [+-]hh[:mm[:ss]] - int sign = 1; - if (*p == '-') { - sign = -1; - p++; - } else if (*p == '+') { - p++; - } - - int hours = 0; - while (*p && std::isdigit(static_cast(*p))) { - hours = hours * 10 + (*p - '0'); - p++; - } - - int minutes = 0; - if (*p == ':') { - p++; - while (*p && std::isdigit(static_cast(*p))) { - minutes = minutes * 10 + (*p - '0'); - p++; - } - } - - int seconds = 0; - if (*p == ':') { - p++; - while (*p && std::isdigit(static_cast(*p))) { - seconds = seconds * 10 + (*p - '0'); - p++; - } - } - - rule.time_seconds = sign * (hours * 3600 + minutes * 60 + seconds); + rule.time_seconds = parse_offset(p); } } void julian_to_month_day(int julian_day, int year, int &out_month, int &out_day) { + (void) year; // Unused - J format ignores leap years by design // J format: day 1-365, Feb 29 is NOT counted even in leap years // So day 60 is always March 1 static const int DAYS_BEFORE_MONTH[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; @@ -246,38 +208,21 @@ bool parse_dst_rule(const char *&p, DSTRule &rule) { rule.type = DSTRuleType::MONTH_WEEK_DAY; p++; - // Parse month - rule.month = 0; - while (*p && std::isdigit(static_cast(*p))) { - rule.month = rule.month * 10 + (*p - '0'); - p++; - } + rule.month = parse_uint(p); if (rule.month < 1 || rule.month > 12) return false; - if (*p != '.') + if (*p++ != '.') return false; - p++; - // Parse week (1-5, where 5 means "last") - rule.week = 0; - while (*p && std::isdigit(static_cast(*p))) { - rule.week = rule.week * 10 + (*p - '0'); - p++; - } + rule.week = parse_uint(p); if (rule.week < 1 || rule.week > 5) return false; - if (*p != '.') + if (*p++ != '.') return false; - p++; - // Parse day of week (0 = Sunday) - rule.day_of_week = 0; - while (*p && std::isdigit(static_cast(*p))) { - rule.day_of_week = rule.day_of_week * 10 + (*p - '0'); - p++; - } + rule.day_of_week = parse_uint(p); if (rule.day_of_week > 6) return false; @@ -286,11 +231,7 @@ bool parse_dst_rule(const char *&p, DSTRule &rule) { rule.type = DSTRuleType::JULIAN_NO_LEAP; p++; - rule.day = 0; - while (*p && std::isdigit(static_cast(*p))) { - rule.day = rule.day * 10 + (*p - '0'); - p++; - } + rule.day = parse_uint(p); if (rule.day < 1 || rule.day > 365) return false; @@ -298,11 +239,7 @@ bool parse_dst_rule(const char *&p, DSTRule &rule) { // Plain number format: n (day 0-365, counting Feb 29) rule.type = DSTRuleType::DAY_OF_YEAR; - rule.day = 0; - while (*p && std::isdigit(static_cast(*p))) { - rule.day = rule.day * 10 + (*p - '0'); - p++; - } + rule.day = parse_uint(p); if (rule.day > 365) return false; @@ -479,13 +416,15 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou return false; } - int32_t offset = get_utc_offset(utc_epoch, tz); + // Determine DST status once (avoids duplicate is_in_dst calculation) + bool in_dst = 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) time_t local_epoch = utc_epoch - offset; internal::epoch_to_tm_utc(local_epoch, out_tm); - out_tm->tm_isdst = is_in_dst(utc_epoch, tz) ? 1 : 0; + out_tm->tm_isdst = in_dst ? 1 : 0; return true; } diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 6d76c4142c..7880d52f8d 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -2,7 +2,6 @@ #include #include -#include namespace esphome { From e1df75fc9b1e4fb618cf31ad7e21fe159b6e8d1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:53:06 -0600 Subject: [PATCH 07/61] tweak --- esphome/components/time/posix_tz.cpp | 3 +-- esphome/components/time/posix_tz.h | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 02f47d3a7d..1186b3064c 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -160,8 +160,7 @@ static void parse_transition_time(const char *&p, DSTRule &rule) { } } -void julian_to_month_day(int julian_day, int year, int &out_month, int &out_day) { - (void) year; // Unused - J format ignores leap years by design +void 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 static const int DAYS_BEFORE_MONTH[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 7880d52f8d..70f2a84851 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -94,10 +94,9 @@ 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 year The year (for leap year calculation) /// @param[out] month Output: month 1-12 /// @param[out] day Output: day of month -void julian_to_month_day(int julian_day, int year, int &month, int &day); +void julian_to_month_day(int julian_day, int &month, int &day); /// Convert day of year (plain format, 0-365 counting Feb 29) to month/day /// @param day_of_year Day number 0-365 From c1971955a3354a9fd6785bc02afe078ff7121ddb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:53:43 -0600 Subject: [PATCH 08/61] tweak --- esphome/components/time/posix_tz.cpp | 2 +- tests/components/time/posix_tz_parser.cpp | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 1186b3064c..178eaff191 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -354,7 +354,7 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs case DSTRuleType::JULIAN_NO_LEAP: // J format: day 1-365, Feb 29 not counted - internal::julian_to_month_day(rule.day, year, month, day); + internal::julian_to_month_day(rule.day, month, day); break; case DSTRuleType::DAY_OF_YEAR: diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 6c1fea36e5..aab714614c 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -278,12 +278,9 @@ TEST(PosixTzParser, PlainDayInvalidDay366) { // ============================================================================ TEST(PosixTzParser, JulianDay60IsMarch1) { - // J60 is always March 1, regardless of leap year + // J60 is always March 1 (J format ignores leap years by design) int month, day; - internal::julian_to_month_day(60, 2024, month, day); // Leap year - EXPECT_EQ(month, 3); - EXPECT_EQ(day, 1); - internal::julian_to_month_day(60, 2025, month, day); // Non-leap year + internal::julian_to_month_day(60, month, day); EXPECT_EQ(month, 3); EXPECT_EQ(day, 1); } @@ -318,14 +315,14 @@ TEST(PosixTzParser, LeapYearDetection) { TEST(PosixTzParser, JulianDay1IsJan1) { int month, day; - internal::julian_to_month_day(1, 2025, month, day); + internal::julian_to_month_day(1, month, day); EXPECT_EQ(month, 1); EXPECT_EQ(day, 1); } TEST(PosixTzParser, JulianDay365IsDec31) { int month, day; - internal::julian_to_month_day(365, 2025, month, day); + internal::julian_to_month_day(365, month, day); EXPECT_EQ(month, 12); EXPECT_EQ(day, 31); } From a1cdfe71dea1463bc5a0895d7b59cc916cb0fa41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:54:40 -0600 Subject: [PATCH 09/61] tweak --- esphome/components/time/posix_tz.cpp | 7 ------- esphome/components/time/posix_tz.h | 6 ------ 2 files changed, 13 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 178eaff191..972092fb7c 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -403,13 +403,6 @@ bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { } } -int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz) { - if (is_in_dst(utc_epoch, tz)) { - return tz.dst_offset_seconds; - } - return tz.std_offset_seconds; -} - bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 70f2a84851..ed7a1d8120 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -66,12 +66,6 @@ bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); /// @return true on success bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm); -/// Get the current offset from UTC in seconds for a given epoch. -/// @param utc_epoch Unix timestamp in UTC -/// @param tz The parsed timezone -/// @return Offset in seconds (positive = behind UTC, negative = ahead) -int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz); - // Internal helper functions exposed for testing namespace internal { From fc951baebc79fa06ecef332e873e2eacda23d9dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 21:59:46 -0600 Subject: [PATCH 10/61] tweak --- esphome/components/time/posix_tz.cpp | 57 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 972092fb7c..28223ee2d1 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -17,6 +17,23 @@ static uint32_t parse_uint(const char *&p) { bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } +// Extract just the year from a UTC epoch (faster than full epoch_to_tm_utc) +static int epoch_to_year(time_t epoch) { + int64_t days = epoch / 86400; + if (epoch < 0 && epoch % 86400 != 0) + days--; + int year = 1970; + while (days >= (is_leap_year(year) ? 366 : 365)) { + days -= is_leap_year(year) ? 366 : 365; + year++; + } + while (days < 0) { + year--; + days += is_leap_year(year) ? 366 : 365; + } + return year; +} + int days_in_month(int year, int month) { static const int DAYS_PER_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && is_leap_year(year)) @@ -198,9 +215,7 @@ void day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &ou } bool parse_dst_rule(const char *&p, DSTRule &rule) { - // Initialize defaults - rule = {}; - rule.time_seconds = 2 * 3600; // Default 02:00 + rule = {}; // Zero initialize if (*p == 'M' || *p == 'm') { // M format: Mm.w.d (month.week.day) @@ -281,9 +296,7 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { // Check for DST name if (!*p) { - // No DST - result.has_dst = false; - return true; + return true; // No DST } // If next char is comma, there's no DST name but there are rules (invalid) @@ -292,9 +305,7 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { } if (!internal::skip_tz_name(p)) { - // No valid DST name, no DST - result.has_dst = false; - return true; + return true; // No valid DST name, no DST } // We have a DST name @@ -363,19 +374,18 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs break; } - // Build the tm struct for this date at the transition time - struct tm transition_tm = {}; - transition_tm.tm_year = year - 1900; - transition_tm.tm_mon = month - 1; - transition_tm.tm_mday = day; - transition_tm.tm_hour = rule.time_seconds / 3600; - transition_tm.tm_min = (rule.time_seconds % 3600) / 60; - transition_tm.tm_sec = rule.time_seconds % 60; + // 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 UTC epoch, then add the base offset - // (transition times are specified in local time before the transition) - time_t local_epoch = internal::tm_to_epoch_utc(&transition_tm); - return local_epoch + base_offset_seconds; + // 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) { @@ -383,10 +393,7 @@ bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { return false; } - // Get the year from the UTC epoch - struct tm utc_tm; - internal::epoch_to_tm_utc(utc_epoch, &utc_tm); - int year = utc_tm.tm_year + 1900; + int year = internal::epoch_to_year(utc_epoch); // Calculate DST start and end for this year // DST start transition happens in standard time From 85c814b712066b462a96a5fa45bade19aa1bf7cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:02:46 -0600 Subject: [PATCH 11/61] tweak --- esphome/components/time/posix_tz.cpp | 22 ------ esphome/components/time/posix_tz.h | 3 - tests/components/time/posix_tz_parser.cpp | 96 ++++++----------------- 3 files changed, 26 insertions(+), 95 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 28223ee2d1..a03ad9a119 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -99,28 +99,6 @@ void epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { out_tm->tm_isdst = 0; } -time_t tm_to_epoch_utc(const struct tm *tm) { - int year = tm->tm_year + 1900; - int month = tm->tm_mon + 1; - int day = tm->tm_mday; - - // Days from epoch to start of year - int64_t days = 0; - for (int y = 1970; y < year; y++) { - days += is_leap_year(y) ? 366 : 365; - } - - // Days from start of year to start of month - for (int m = 1; m < month; m++) { - days += days_in_month(year, m); - } - - // Days in current month - days += day - 1; - - return days * 86400 + tm->tm_hour * 3600 + tm->tm_min * 60 + tm->tm_sec; -} - bool skip_tz_name(const char *&p) { if (*p == '<') { // Angle-bracket quoted name: <+07>, <-03>, diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index ed7a1d8120..6c22b6e1da 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -112,9 +112,6 @@ 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); -/// Convert tm struct to epoch (UTC) -time_t tm_to_epoch_utc(const struct tm *tm); - } // namespace internal } // namespace esphome diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index aab714614c..3990d44ece 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -9,6 +9,20 @@ namespace esphome::time::testing { +// Helper to create UTC epoch from date/time components (for test readability) +static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0, int sec = 0) { + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365; + } + static const int DAYS_BEFORE[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + days += DAYS_BEFORE[month - 1]; + if (month > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) + days++; // Leap year adjustment + days += day - 1; + return days * 86400 + hour * 3600 + min * 60 + sec; +} + // ============================================================================ // Basic TZ string parsing tests // ============================================================================ @@ -436,12 +450,7 @@ TEST(PosixTzParser, IsInDstUSEasternSummer) { parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); // July 4, 2026 12:00 UTC - definitely in DST - struct tm july4 {}; - july4.tm_hour = 12; - july4.tm_mday = 4; - july4.tm_mon = 6; - july4.tm_year = 126; - time_t summer = internal::tm_to_epoch_utc(&july4); + time_t summer = make_utc(2026, 7, 4, 12); EXPECT_TRUE(is_in_dst(summer, tz)); } @@ -450,12 +459,7 @@ TEST(PosixTzParser, IsInDstUSEasternWinter) { parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); // January 15, 2026 12:00 UTC - definitely not in DST - struct tm jan15 {}; - jan15.tm_hour = 12; - jan15.tm_mday = 15; - jan15.tm_mon = 0; - jan15.tm_year = 126; - time_t winter = internal::tm_to_epoch_utc(&jan15); + time_t winter = make_utc(2026, 1, 15, 12); EXPECT_FALSE(is_in_dst(winter, tz)); } @@ -463,12 +467,8 @@ TEST(PosixTzParser, IsInDstNoDstTimezone) { ParsedTimezone tz; parse_posix_tz("IST-5:30", tz); - struct tm anytime {}; - anytime.tm_hour = 12; - anytime.tm_mday = 15; - anytime.tm_mon = 6; - anytime.tm_year = 126; - time_t epoch = internal::tm_to_epoch_utc(&anytime); + // July 15, 2026 12:00 UTC + time_t epoch = make_utc(2026, 7, 15, 12); EXPECT_FALSE(is_in_dst(epoch, tz)); } @@ -477,12 +477,7 @@ TEST(PosixTzParser, SouthernHemisphereDstSummer) { parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); // December 15, 2025 12:00 UTC - summer in NZ, should be in DST - struct tm dec15 {}; - dec15.tm_hour = 12; - dec15.tm_mday = 15; - dec15.tm_mon = 11; - dec15.tm_year = 125; - time_t nz_summer = internal::tm_to_epoch_utc(&dec15); + time_t nz_summer = make_utc(2025, 12, 15, 12); EXPECT_TRUE(is_in_dst(nz_summer, tz)); } @@ -491,12 +486,7 @@ TEST(PosixTzParser, SouthernHemisphereDstWinter) { parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST - struct tm july15 {}; - july15.tm_hour = 12; - july15.tm_mday = 15; - july15.tm_mon = 6; - july15.tm_year = 126; - time_t nz_winter = internal::tm_to_epoch_utc(&july15); + time_t nz_winter = make_utc(2026, 7, 15, 12); EXPECT_FALSE(is_in_dst(nz_winter, tz)); } @@ -522,12 +512,7 @@ TEST(PosixTzParser, EpochToLocalWithOffset) { parse_posix_tz("EST5", tz); // UTC-5 // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST - struct tm utc_tm {}; - utc_tm.tm_hour = 5; - utc_tm.tm_mday = 1; - utc_tm.tm_mon = 0; - utc_tm.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); + time_t utc_epoch = make_utc(2026, 1, 1, 5); struct tm local; ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); @@ -541,12 +526,7 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); // July 4, 2026 16:00 UTC = 12:00 EDT (noon) - struct tm july4_utc {}; - july4_utc.tm_hour = 16; - july4_utc.tm_mday = 4; - july4_utc.tm_mon = 6; - july4_utc.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); + time_t utc_epoch = make_utc(2026, 7, 4, 16); struct tm local; ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local)); @@ -626,23 +606,11 @@ TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 6; - before.tm_mday = 8; - before.tm_mon = 2; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); + time_t before_epoch = make_utc(2026, 3, 8, 6, 59, 59); EXPECT_FALSE(is_in_dst(before_epoch, tz)); // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) - struct tm after {}; - after.tm_hour = 7; - after.tm_mday = 8; - after.tm_mon = 2; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); + time_t after_epoch = make_utc(2026, 3, 8, 7); EXPECT_TRUE(is_in_dst(after_epoch, tz)); } @@ -652,23 +620,11 @@ TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 5; - before.tm_mday = 1; - before.tm_mon = 10; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); + time_t before_epoch = make_utc(2026, 11, 1, 5, 59, 59); EXPECT_TRUE(is_in_dst(before_epoch, tz)); // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) - struct tm after {}; - after.tm_hour = 6; - after.tm_mday = 1; - after.tm_mon = 10; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); + time_t after_epoch = make_utc(2026, 11, 1, 6); EXPECT_FALSE(is_in_dst(after_epoch, tz)); } From 34ec72ad496d81c7c37f9f889d6adeb35920245d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:05:23 -0600 Subject: [PATCH 12/61] tweak --- esphome/components/time/posix_tz.cpp | 4 ++-- esphome/components/time/posix_tz.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index a03ad9a119..f878af5118 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -1,7 +1,7 @@ #include "posix_tz.h" #include -namespace esphome { +namespace esphome::time { namespace internal { @@ -406,4 +406,4 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou return true; } -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 6c22b6e1da..8ab577dd83 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -3,7 +3,7 @@ #include #include -namespace esphome { +namespace esphome::time { /// Type of DST transition rule enum class DSTRuleType : uint8_t { @@ -114,4 +114,4 @@ void epoch_to_tm_utc(time_t epoch, struct tm *out_tm); } // namespace internal -} // namespace esphome +} // namespace esphome::time From d2bc168f39eab9c87131faac0b4ce1884d2c7676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:07:34 -0600 Subject: [PATCH 13/61] tweak --- esphome/components/time/posix_tz.cpp | 150 +++++++++++----------- esphome/components/time/posix_tz.h | 26 ++-- tests/components/time/posix_tz_parser.cpp | 28 ++-- 3 files changed, 102 insertions(+), 102 deletions(-) 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 From 53fb8767387664e2e061e060cf37d47f96772eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:17:36 -0600 Subject: [PATCH 14/61] tests --- tests/components/time/posix_tz_parser.cpp | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 43c2dad9a7..d75f3e5690 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -287,6 +287,98 @@ TEST(PosixTzParser, PlainDayInvalidDay366) { EXPECT_FALSE(parse_posix_tz("EST5EDT,366,304", tz)); } +// ============================================================================ +// Transition time edge cases (POSIX V3 allows -167 to +167 hours) +// ============================================================================ + +TEST(PosixTzParser, NegativeTransitionTime) { + ParsedTimezone tz; + // Negative transition time: /-1 means 11 PM (23:00) the previous day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1,M11.1.0/2", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, -1 * 3600); // -1 hour = 11 PM previous day + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, NegativeTransitionTimeWithMinutes) { + ParsedTimezone tz; + // /-1:30 means 10:30 PM the previous day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1:30,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, -(1 * 3600 + 30 * 60)); +} + +TEST(PosixTzParser, LargeTransitionTime) { + ParsedTimezone tz; + // POSIX V3 allows transition times from -167 to +167 hours + // /25 means 1:00 AM the next day + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/25,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 25 * 3600); +} + +TEST(PosixTzParser, MaxTransitionTime167Hours) { + ParsedTimezone tz; + // Maximum allowed transition time per POSIX V3 + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/167,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 167 * 3600); +} + +TEST(PosixTzParser, TransitionTimeWithHoursMinutesSeconds) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2:30:45,M11.1.0", tz)); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600 + 30 * 60 + 45); +} + +// ============================================================================ +// Invalid M format tests +// ============================================================================ + +TEST(PosixTzParser, MFormatInvalidMonth13) { + ParsedTimezone tz; + // Month must be 1-12 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M13.1.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidMonth0) { + ParsedTimezone tz; + // Month must be 1-12 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M0.1.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidWeek6) { + ParsedTimezone tz; + // Week must be 1-5 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.6.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidWeek0) { + ParsedTimezone tz; + // Week must be 1-5 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.0.0,M11.1.0", tz)); +} + +TEST(PosixTzParser, MFormatInvalidDayOfWeek7) { + ParsedTimezone tz; + // Day of week must be 0-6 + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.7,M11.1.0", tz)); +} + +// ============================================================================ +// Large offset tests +// ============================================================================ + +TEST(PosixTzParser, MaxOffset14Hours) { + ParsedTimezone tz; + // Line Islands (Kiribati) is UTC+14, the maximum offset + ASSERT_TRUE(parse_posix_tz("<+14>-14", tz)); + EXPECT_EQ(tz.std_offset_seconds, -14 * 3600); +} + +TEST(PosixTzParser, MaxNegativeOffset12Hours) { + ParsedTimezone tz; + // Baker Island is UTC-12 + ASSERT_TRUE(parse_posix_tz("<-12>12", tz)); + EXPECT_EQ(tz.std_offset_seconds, 12 * 3600); +} + // ============================================================================ // Helper function tests // ============================================================================ From 973105f2e534f5b172f228c33becdfaaab18fbe7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:28:09 -0600 Subject: [PATCH 15/61] tweak --- esphome/core/time.cpp | 159 +++++++++++++++------- tests/components/time/posix_tz_parser.cpp | 134 +++++++++++++++++- 2 files changed, 241 insertions(+), 52 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 554431c631..29be550bd6 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -2,7 +2,6 @@ #include "helpers.h" #include -#include namespace esphome { @@ -67,58 +66,120 @@ std::string ESPTime::strftime(const char *format) { std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } -bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { - uint16_t year; - uint8_t month; - uint8_t day; - uint8_t hour; - uint8_t minute; - uint8_t second; - int num; - const int ilen = static_cast(len); - - if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, // NOLINT - &second, &num) == 6 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = second; - } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, &num) == 5 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = 0; - } else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT - num == ilen) { - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = second; - } else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT - num == ilen) { - esp_time.hour = hour; - esp_time.minute = minute; - esp_time.second = 0; - } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT - num == ilen) { - esp_time.year = year; - esp_time.month = month; - esp_time.day_of_month = day; - } else { - return false; +// Helper to parse exactly N digits, returns false if not enough digits +static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) { + value = 0; + for (int i = 0; i < count; i++) { + if (p >= end || *p < '0' || *p > '9') + return false; + value = value * 10 + (*p - '0'); + p++; } return true; } +// Helper to check for expected character +static bool expect_char(const char *&p, const char *end, char expected) { + if (p >= end || *p != expected) + return false; + p++; + return true; +} + +bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { + // Supported formats: + // YYYY-MM-DD HH:MM:SS (19 chars) + // YYYY-MM-DD HH:MM (16 chars) + // YYYY-MM-DD (10 chars) + // HH:MM:SS (8 chars) + // HH:MM (5 chars) + + const char *p = time_to_parse; + const char *end = time_to_parse + len; + uint16_t v1, v2, v3, v4, v5, v6; + + // Try date formats first (start with 4-digit year) + if (len >= 10 && time_to_parse[4] == '-') { + // YYYY-MM-DD... + if (!parse_digits(p, end, 4, v1)) + return false; + if (!expect_char(p, end, '-')) + return false; + if (!parse_digits(p, end, 2, v2)) + return false; + if (!expect_char(p, end, '-')) + return false; + if (!parse_digits(p, end, 2, v3)) + return false; + + esp_time.year = v1; + esp_time.month = v2; + esp_time.day_of_month = v3; + + if (p == end) { + // YYYY-MM-DD (date only) + return true; + } + + if (!expect_char(p, end, ' ')) + return false; + + // Continue with time part: HH:MM[:SS] + if (!parse_digits(p, end, 2, v4)) + return false; + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v5)) + return false; + + esp_time.hour = v4; + esp_time.minute = v5; + + if (p == end) { + // YYYY-MM-DD HH:MM + esp_time.second = 0; + return true; + } + + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v6)) + return false; + + esp_time.second = v6; + return p == end; // YYYY-MM-DD HH:MM:SS + } + + // Try time-only formats (HH:MM[:SS]) + if (len >= 5 && time_to_parse[2] == ':') { + if (!parse_digits(p, end, 2, v1)) + return false; + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v2)) + return false; + + esp_time.hour = v1; + esp_time.minute = v2; + + if (p == end) { + // HH:MM + esp_time.second = 0; + return true; + } + + if (!expect_char(p, end, ':')) + return false; + if (!parse_digits(p, end, 2, v3)) + return false; + + esp_time.second = v3; + return p == end; // HH:MM:SS + } + + return false; +} + void ESPTime::increment_second() { this->timestamp++; if (!increment_time_value(this->second, 0, 60)) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index d75f3e5690..38f8640ec9 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1,11 +1,11 @@ -// Tests for the POSIX TZ parser implementation -// This verifies our custom parser produces identical results to libc's -// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). +// Tests for the POSIX TZ parser and ESPTime::strptime implementations +// These custom parsers avoid pulling in scanf (~9.8KB on ESP32-IDF). #include #include #include #include "esphome/components/time/posix_tz.h" +#include "esphome/core/time.h" namespace esphome::time::testing { @@ -721,3 +721,131 @@ TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { } } // namespace esphome::time::testing + +// ============================================================================ +// ESPTime::strptime tests (replaces sscanf-based parsing) +// ============================================================================ + +namespace esphome::testing { + +TEST(ESPTimeStrptime, FullDateTime) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30:45", 19, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 45); +} + +TEST(ESPTimeStrptime, DateTimeNoSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30", 16, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, DateOnly) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-03-15", 10, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 3); + EXPECT_EQ(t.day_of_month, 15); +} + +TEST(ESPTimeStrptime, TimeWithSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("14:30:45", 8, t)); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 45); +} + +TEST(ESPTimeStrptime, TimeNoSeconds) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("14:30", 5, t)); + EXPECT_EQ(t.hour, 14); + EXPECT_EQ(t.minute, 30); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, Midnight) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("00:00:00", 8, t)); + EXPECT_EQ(t.hour, 0); + EXPECT_EQ(t.minute, 0); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeStrptime, EndOfDay) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("23:59:59", 8, t)); + EXPECT_EQ(t.hour, 23); + EXPECT_EQ(t.minute, 59); + EXPECT_EQ(t.second, 59); +} + +TEST(ESPTimeStrptime, LeapYearDate) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2024-02-29", 10, t)); + EXPECT_EQ(t.year, 2024); + EXPECT_EQ(t.month, 2); + EXPECT_EQ(t.day_of_month, 29); +} + +TEST(ESPTimeStrptime, NewYearsEve) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("2026-12-31 23:59:59", 19, t)); + EXPECT_EQ(t.year, 2026); + EXPECT_EQ(t.month, 12); + EXPECT_EQ(t.day_of_month, 31); + EXPECT_EQ(t.hour, 23); + EXPECT_EQ(t.minute, 59); + EXPECT_EQ(t.second, 59); +} + +TEST(ESPTimeStrptime, EmptyStringFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("", 0, t)); +} + +TEST(ESPTimeStrptime, InvalidFormatFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("not-a-date", 10, t)); +} + +TEST(ESPTimeStrptime, PartialDateFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("2026-03", 7, t)); +} + +TEST(ESPTimeStrptime, PartialTimeFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("14:", 3, t)); +} + +TEST(ESPTimeStrptime, ExtraCharactersFails) { + ESPTime t{}; + // Full datetime with extra characters should fail + EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t)); +} + +TEST(ESPTimeStrptime, WrongSeparatorFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime("2026/03/15", 10, t)); +} + +TEST(ESPTimeStrptime, LeadingZeroTime) { + ESPTime t{}; + ASSERT_TRUE(ESPTime::strptime("01:05:09", 8, t)); + EXPECT_EQ(t.hour, 1); + EXPECT_EQ(t.minute, 5); + EXPECT_EQ(t.second, 9); +} + +} // namespace esphome::testing From bec7d6d223c48be4e35e5ac90eb55fbf7fe09c12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:31:23 -0600 Subject: [PATCH 16/61] tweak --- esphome/components/time/posix_tz.cpp | 14 ++++++++------ tests/components/time/posix_tz_parser.cpp | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 7a122b474a..f91c168811 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -370,18 +370,20 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { result.dst_offset_seconds = result.std_offset_seconds - 3600; } - // Parse DST rules if present + // Parse DST rules if present (POSIX requires both start and end if any rules specified) if (*p == ',') { p++; if (!internal::parse_dst_rule(p, result.dst_start)) { return false; } - if (*p == ',') { - p++; - if (!internal::parse_dst_rule(p, result.dst_end)) { - return false; - } + // Second rule is required per POSIX + if (*p != ',') { + return false; + } + p++; + if (!internal::parse_dst_rule(p, result.dst_end)) { + return false; } } diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 38f8640ec9..7badaa37f0 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -361,6 +361,24 @@ TEST(PosixTzParser, MFormatInvalidDayOfWeek7) { EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.7,M11.1.0", tz)); } +TEST(PosixTzParser, MissingEndRule) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.0", tz)); +} + +TEST(PosixTzParser, MissingEndRuleJFormat) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,J60", tz)); +} + +TEST(PosixTzParser, MissingEndRulePlainDay) { + ParsedTimezone tz; + // POSIX requires both start and end rules if any rules are specified + EXPECT_FALSE(parse_posix_tz("EST5EDT,60", tz)); +} + // ============================================================================ // Large offset tests // ============================================================================ From 5d49c81e2d39f162484762e1b2fe25ed1be731a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:42:33 -0600 Subject: [PATCH 17/61] more cover --- esphome/core/time.cpp | 3 + tests/components/time/posix_tz_parser.cpp | 78 +++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 29be550bd6..a31b863213 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -94,6 +94,9 @@ bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) // HH:MM:SS (8 chars) // HH:MM (5 chars) + if (time_to_parse == nullptr || len == 0) + return false; + const char *p = time_to_parse; const char *end = time_to_parse + len; uint16_t v1, v2, v3, v4, v5, v6; diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 7badaa37f0..99632f519d 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -379,6 +379,57 @@ TEST(PosixTzParser, MissingEndRulePlainDay) { EXPECT_FALSE(parse_posix_tz("EST5EDT,60", tz)); } +TEST(PosixTzParser, LowercaseMFormat) { + ParsedTimezone tz; + // Lowercase 'm' should be accepted + ASSERT_TRUE(parse_posix_tz("EST5EDT,m3.2.0,m11.1.0", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_end.month, 11); +} + +TEST(PosixTzParser, LowercaseJFormat) { + ParsedTimezone tz; + // Lowercase 'j' should be accepted + ASSERT_TRUE(parse_posix_tz("EST5EDT,j60,j305", tz)); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_start.day, 60); +} + +TEST(PosixTzParser, DstNameWithoutRules) { + ParsedTimezone tz; + // DST name present but no rules - should have has_dst=true with default offset + ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: std - 1 hour +} + +TEST(PosixTzParser, TrailingCharactersIgnored) { + ParsedTimezone tz; + // Trailing characters after valid TZ should be ignored (parser stops at end of valid input) + // This matches libc behavior + ASSERT_TRUE(parse_posix_tz("EST5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); +} + +TEST(PosixTzParser, PlainDay365LeapYear) { + // Day 365 in leap year is Dec 31 + int month, day; + internal::day_of_year_to_month_day(365, 2024, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, PlainDay365NonLeapYear) { + // Day 365 in non-leap year would be Jan 1 of next year (out of range) + // But our function should handle it gracefully + int month, day; + internal::day_of_year_to_month_day(364, 2025, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); // Day 364 is Dec 31 in non-leap year +} + // ============================================================================ // Large offset tests // ============================================================================ @@ -617,6 +668,28 @@ TEST(PosixTzParser, EpochToLocalBasic) { EXPECT_EQ(local.tm_hour, 0); } +TEST(PosixTzParser, EpochToLocalNegativeEpoch) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + // Dec 31, 1969 23:59:59 UTC (1 second before epoch) + time_t epoch = -1; + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 69); // 1969 + EXPECT_EQ(local.tm_mon, 11); // December + EXPECT_EQ(local.tm_mday, 31); + EXPECT_EQ(local.tm_hour, 23); + EXPECT_EQ(local.tm_min, 59); + EXPECT_EQ(local.tm_sec, 59); +} + +TEST(PosixTzParser, EpochToLocalNullTmFails) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr)); +} + TEST(PosixTzParser, EpochToLocalWithOffset) { ParsedTimezone tz; parse_posix_tz("EST5", tz); // UTC-5 @@ -832,6 +905,11 @@ TEST(ESPTimeStrptime, EmptyStringFails) { EXPECT_FALSE(ESPTime::strptime("", 0, t)); } +TEST(ESPTimeStrptime, NullInputFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime(nullptr, 0, t)); +} + TEST(ESPTimeStrptime, InvalidFormatFails) { ESPTime t{}; EXPECT_FALSE(ESPTime::strptime("not-a-date", 10, t)); From bd056b3b9ed81c2c0d28b5c1e027851c45bc70b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:47:54 -0600 Subject: [PATCH 18/61] improve readability --- esphome/components/time/posix_tz.cpp | 85 ++++++++++++++++------------ 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index f91c168811..0d1a5d8863 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -17,23 +17,32 @@ static uint32_t parse_uint(const char *&p) { bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } -// Extract just the year from a UTC epoch (faster than full epoch_to_tm_utc) -static int epoch_to_year(time_t epoch) { - int64_t days = epoch / 86400; - if (epoch < 0 && epoch % 86400 != 0) - days--; +// 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; - while (days >= (is_leap_year(year) ? 366 : 365)) { - days -= is_leap_year(year) ? 366 : 365; + int diy; + while (days >= (diy = days_in_year(year))) { + days -= diy; year++; } while (days < 0) { year--; - days += is_leap_year(year) ? 366 : 365; + days += days_in_year(year); } return year; } +// Extract just the year from a UTC epoch +static int epoch_to_year(time_t epoch) { + int64_t days = epoch / 86400; + if (epoch < 0 && epoch % 86400 != 0) + days--; + return days_to_year(days); +} + int days_in_month(int year, int month) { static const int DAYS_PER_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && is_leap_year(year)) @@ -55,7 +64,7 @@ int day_of_week(int year, int month, int day) { return ((h + 6) % 7); } -void epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { +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; @@ -74,23 +83,16 @@ void epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { if (out_tm->tm_wday < 0) out_tm->tm_wday += 7; - // Calculate year, month, day - int year = 1970; - while (days >= (is_leap_year(year) ? 366 : 365)) { - days -= is_leap_year(year) ? 366 : 365; - year++; - } - while (days < 0) { - year--; - days += is_leap_year(year) ? 366 : 365; - } - + // Calculate year (updates days to day-of-year) + int year = days_to_year(days); out_tm->tm_year = year - 1900; out_tm->tm_yday = static_cast(days); + // Calculate month and day int month = 1; - while (days >= days_in_month(year, month)) { - days -= days_in_month(year, month); + int dim; + while (days >= (dim = days_in_month(year, month))) { + days -= dim; month++; } @@ -245,23 +247,41 @@ 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) { +// 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; + for (int m = 1; m < month; m++) { + days += days_in_month(year, m); + } + return days; +} + +// Calculate days from epoch to Jan 1 of given year +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; 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); + int first_dow = 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 days_until_first = (rule.day_of_week - first_dow + 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); + int dim = days_in_month(year, rule.month); day = first_occurrence; - while (day + 7 <= days_in_m) { + while (day + 7 <= dim) { day += 7; } } else { @@ -284,20 +304,13 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs } // 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; + 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; } -bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { +bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { if (!tz.has_dst) { return false; } From 8708f96de4c4d2fc586603b60987879f37f7acb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:53:29 -0600 Subject: [PATCH 19/61] less ram --- esphome/components/time/posix_tz.cpp | 40 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 0d1a5d8863..7a6d77db61 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -44,14 +44,21 @@ static int epoch_to_year(time_t epoch) { } int days_in_month(int year, int month) { - static const int DAYS_PER_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; - if (month == 2 && is_leap_year(year)) - return 29; - return DAYS_PER_MONTH[month - 1]; + switch (month) { + case 2: + return is_leap_year(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } } // Zeller-like algorithm for day of week (0 = Sunday) -int day_of_week(int year, int month, int day) { +int __attribute__((noinline)) day_of_week(int year, int month, int day) { // Adjust for January/February if (month < 3) { month += 12; @@ -123,7 +130,7 @@ bool skip_tz_name(const char *&p) { return (p - start) >= 3; } -int32_t parse_offset(const char *&p) { +int32_t __attribute__((noinline)) parse_offset(const char *&p) { int sign = 1; if (*p == '-') { sign = -1; @@ -157,23 +164,26 @@ static void parse_transition_time(const char *&p, DSTRule &rule) { } } -void julian_to_month_day(int julian_day, int &out_month, int &out_day) { +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 - static const int DAYS_BEFORE_MONTH[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; - + // Iterate forward through months (no array needed) + int remaining = julian_day; out_month = 1; - for (int m = 11; m >= 0; m--) { - if (julian_day > DAYS_BEFORE_MONTH[m]) { - out_month = m + 1; - out_day = julian_day - DAYS_BEFORE_MONTH[m]; + while (out_month <= 12) { + // Days in month for non-leap year (J format ignores leap years) + int dim = days_in_month(2001, out_month); // 2001 is non-leap year + if (remaining <= dim) { + out_day = remaining; return; } + remaining -= dim; + out_month++; } - out_day = julian_day; + out_day = remaining; } -void day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) { +void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) { // Plain format: day 0-365, Feb 29 IS counted in leap years // Day 0 = Jan 1 int remaining = day_of_year; From a946aefbed248589789a4ca48aa27130442eaa1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:54:56 -0600 Subject: [PATCH 20/61] more cover --- tests/components/time/posix_tz_parser.cpp | 38 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 99632f519d..73b84bed23 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -495,6 +495,27 @@ TEST(PosixTzParser, JulianDay1IsJan1) { EXPECT_EQ(day, 1); } +TEST(PosixTzParser, JulianDay31IsJan31) { + int month, day; + internal::julian_to_month_day(31, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, JulianDay32IsFeb1) { + int month, day; + internal::julian_to_month_day(32, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, JulianDay59IsFeb28) { + int month, day; + internal::julian_to_month_day(59, month, day); + EXPECT_EQ(month, 2); + EXPECT_EQ(day, 28); +} + TEST(PosixTzParser, JulianDay365IsDec31) { int month, day; internal::julian_to_month_day(365, month, day); @@ -510,10 +531,19 @@ TEST(PosixTzParser, DayOfYear0IsJan1) { } TEST(PosixTzParser, DaysInMonthRegular) { - EXPECT_EQ(internal::days_in_month(2025, 1), 31); - EXPECT_EQ(internal::days_in_month(2025, 2), 28); - EXPECT_EQ(internal::days_in_month(2025, 4), 30); - EXPECT_EQ(internal::days_in_month(2025, 12), 31); + // Test all 12 months to ensure switch coverage + EXPECT_EQ(internal::days_in_month(2025, 1), 31); // Jan - default case + EXPECT_EQ(internal::days_in_month(2025, 2), 28); // Feb - case 2 + EXPECT_EQ(internal::days_in_month(2025, 3), 31); // Mar - default case + EXPECT_EQ(internal::days_in_month(2025, 4), 30); // Apr - case 4 + EXPECT_EQ(internal::days_in_month(2025, 5), 31); // May - default case + EXPECT_EQ(internal::days_in_month(2025, 6), 30); // Jun - case 6 + EXPECT_EQ(internal::days_in_month(2025, 7), 31); // Jul - default case + EXPECT_EQ(internal::days_in_month(2025, 8), 31); // Aug - default case + EXPECT_EQ(internal::days_in_month(2025, 9), 30); // Sep - case 9 + EXPECT_EQ(internal::days_in_month(2025, 10), 31); // Oct - default case + EXPECT_EQ(internal::days_in_month(2025, 11), 30); // Nov - case 11 + EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case } TEST(PosixTzParser, DaysInMonthLeapYear) { From 1b7b307d08bfa392d9a3f4f91f226e97d0b003f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 22:57:17 -0600 Subject: [PATCH 21/61] simplify --- esphome/components/time/real_time_clock.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index e04bc3b3d5..034f83b394 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -27,11 +27,7 @@ void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE int std_hours = -this->parsed_tz_.std_offset_seconds / 3600; int std_mins = abs(this->parsed_tz_.std_offset_seconds % 3600) / 60; - if (std_mins == 0) { - ESP_LOGCONFIG(TAG, "Timezone: UTC%+d", std_hours); - } else { - ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); - } + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, From 300eea034bdcc7e46cd68bcbbd55b6a155faf592 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:26:53 -0600 Subject: [PATCH 22/61] handle trailing garbage --- esphome/components/time/posix_tz.cpp | 47 ++++++++++-------- esphome/components/time/real_time_clock.cpp | 54 ++++++++++++++++++--- esphome/components/time/real_time_clock.h | 9 ++-- tests/components/time/posix_tz_parser.cpp | 32 ++++++++++-- 4 files changed, 109 insertions(+), 33 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 7a6d77db61..d400cce454 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -379,12 +379,15 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return false; } - if (!internal::skip_tz_name(p)) { - return true; // No valid DST name, no DST + // 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(*p)) && *p != '<') { + return true; // No DST, trailing characters ignored } - // We have a DST name - result.has_dst = true; + 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(*p)) || *p == '+' || *p == '-')) { @@ -393,23 +396,29 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { result.dst_offset_seconds = result.std_offset_seconds - 3600; } - // Parse DST rules if present (POSIX requires both start and end if any rules specified) - if (*p == ',') { - p++; - if (!internal::parse_dst_rule(p, result.dst_start)) { - return false; - } - - // Second rule is required per POSIX - if (*p != ',') { - return false; - } - p++; - if (!internal::parse_dst_rule(p, result.dst_end)) { - return false; - } + // 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++; + if (!internal::parse_dst_rule(p, result.dst_end)) { + return false; + } + + // Only set has_dst after successfully parsing both rules + result.has_dst = true; + return true; } diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 034f83b394..870d689195 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -23,6 +23,42 @@ static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; +// Helper to format a DST rule for logging +#ifdef USE_TIME_TIMEZONE +static void format_dst_rule(const DSTRule &rule, char *buf, size_t buf_size) { + // Format rule part + int pos = 0; + switch (rule.type) { + case DSTRuleType::MONTH_WEEK_DAY: + pos = snprintf(buf, buf_size, "M%d.%d.%d", rule.month, rule.week, rule.day_of_week); + break; + case DSTRuleType::JULIAN_NO_LEAP: + pos = snprintf(buf, buf_size, "J%d", rule.day); + break; + case DSTRuleType::DAY_OF_YEAR: + pos = snprintf(buf, buf_size, "%d", rule.day); + break; + } + + // Format time part + int32_t time_secs = rule.time_seconds; + char sign = time_secs < 0 ? '-' : '/'; + if (time_secs < 0) + time_secs = -time_secs; + int hours = time_secs / 3600; + int mins = (time_secs % 3600) / 60; + int secs = time_secs % 60; + + if (secs != 0) { + snprintf(buf + pos, buf_size - pos, "%c%d:%02d:%02d", sign, hours, mins, secs); + } else if (mins != 0) { + snprintf(buf + pos, buf_size - pos, "%c%d:%02d", sign, hours, mins); + } else { + snprintf(buf + pos, buf_size - pos, "%c%d", sign, hours); + } +} +#endif + void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE int std_hours = -this->parsed_tz_.std_offset_seconds / 3600; @@ -30,11 +66,10 @@ void RealTimeClock::dump_config() { ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; - ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, - this->parsed_tz_.dst_start.month, this->parsed_tz_.dst_start.week, - this->parsed_tz_.dst_start.day_of_week, this->parsed_tz_.dst_start.time_seconds / 3600, - this->parsed_tz_.dst_end.month, this->parsed_tz_.dst_end.week, this->parsed_tz_.dst_end.day_of_week, - this->parsed_tz_.dst_end.time_seconds / 3600); + char start_buf[24], end_buf[24]; + format_dst_rule(this->parsed_tz_.dst_start, start_buf, sizeof(start_buf)); + format_dst_rule(this->parsed_tz_.dst_end, end_buf, sizeof(end_buf)); + ESP_LOGCONFIG(TAG, " DST: UTC%+d, %s - %s", dst_hours, start_buf, end_buf); } #endif auto time = this->now(); @@ -95,7 +130,14 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { #ifdef USE_TIME_TIMEZONE void RealTimeClock::apply_timezone_(const char *tz) { - // Parse the POSIX TZ string using our custom parser to avoid pulling in scanf (~7.6KB) + // Handle null input + if (tz == nullptr) { + ESP_LOGW(TAG, "Failed to parse timezone: (null)"); + this->parsed_tz_ = ParsedTimezone{}; + return; + } + + // Parse the POSIX TZ string using our custom parser if (!parse_posix_tz(tz, this->parsed_tz_)) { ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); // Reset to UTC on parse failure diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 0f9ec1e993..5b760d23c6 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -26,9 +26,12 @@ class RealTimeClock : public PollingComponent { /// 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 null-terminated string with known length. - /// The length parameter is ignored since our parser uses null-terminated strings. - void set_timezone(const char *tz, size_t /*len*/) { this->apply_timezone_(tz); } + /// Set the time zone from a character buffer with known length. + /// The buffer does not need to be null-terminated; it will be copied. + void set_timezone(const char *tz, size_t len) { + std::string tz_str(tz, len); + this->apply_timezone_(tz_str.c_str()); + } /// Set the time zone from a std::string. void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); } diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 73b84bed23..1a84a39835 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -398,19 +398,19 @@ TEST(PosixTzParser, LowercaseJFormat) { TEST(PosixTzParser, DstNameWithoutRules) { ParsedTimezone tz; - // DST name present but no rules - should have has_dst=true with default offset + // DST name present but no rules - treat as no DST since we can't determine transitions ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); - EXPECT_TRUE(tz.has_dst); + EXPECT_FALSE(tz.has_dst); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); - EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: std - 1 hour } TEST(PosixTzParser, TrailingCharactersIgnored) { ParsedTimezone tz; // Trailing characters after valid TZ should be ignored (parser stops at end of valid input) // This matches libc behavior - ASSERT_TRUE(parse_posix_tz("EST5", tz)); + ASSERT_TRUE(parse_posix_tz("EST5 extra garbage here", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst); } TEST(PosixTzParser, PlainDay365LeapYear) { @@ -751,7 +751,29 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { // Verification against libc // ============================================================================ -class LibcVerificationTest : public ::testing::TestWithParam> {}; +class LibcVerificationTest : public ::testing::TestWithParam> { + protected: + void SetUp() override { + // Save current TZ + const char *current_tz = getenv("TZ"); + saved_tz_ = current_tz ? current_tz : ""; + had_tz_ = current_tz != nullptr; + } + + void TearDown() override { + // Restore TZ + if (had_tz_) { + setenv("TZ", saved_tz_.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); + } + + private: + std::string saved_tz_; + bool had_tz_{false}; +}; TEST_P(LibcVerificationTest, MatchesLibc) { auto [tz_str, epoch] = GetParam(); From 1353dbc31ea06d82f61da5fbfa6a5cc4d768003d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:28:35 -0600 Subject: [PATCH 23/61] cleanup --- esphome/components/time/posix_tz.cpp | 35 +++++++++ esphome/components/time/posix_tz.h | 7 ++ esphome/components/time/real_time_clock.cpp | 40 +--------- tests/components/time/posix_tz_parser.cpp | 84 +++++++++++++++++++++ 4 files changed, 128 insertions(+), 38 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index d400cce454..d54e611443 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -342,6 +342,41 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } +size_t format_dst_rule(const DSTRule &rule, std::span buf) { + // Format rule part + int pos = 0; + switch (rule.type) { + case DSTRuleType::MONTH_WEEK_DAY: + pos = snprintf(buf.data(), buf.size(), "M%d.%d.%d", rule.month, rule.week, rule.day_of_week); + break; + case DSTRuleType::JULIAN_NO_LEAP: + pos = snprintf(buf.data(), buf.size(), "J%d", rule.day); + break; + case DSTRuleType::DAY_OF_YEAR: + pos = snprintf(buf.data(), buf.size(), "%d", rule.day); + break; + } + + // Format time part + int32_t time_secs = rule.time_seconds; + char sign = time_secs < 0 ? '-' : '/'; + if (time_secs < 0) + time_secs = -time_secs; + int hours = time_secs / 3600; + int mins = (time_secs % 3600) / 60; + int secs = time_secs % 60; + + if (secs != 0) { + pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d:%02d:%02d", sign, hours, mins, secs); + } else if (mins != 0) { + pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d:%02d", sign, hours, mins); + } else { + pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d", sign, hours); + } + + return static_cast(pos); +} + } // namespace internal bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index ea9864b304..fa2e7f8ab6 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -2,6 +2,7 @@ #include #include +#include namespace esphome::time { @@ -112,6 +113,12 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs /// @return true if DST is in effect at the given time bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); +/// Format a DST rule for logging/display +/// @param rule The DST rule to format +/// @param buf Output buffer (24 bytes recommended) +/// @return Number of characters written (excluding null terminator) +size_t format_dst_rule(const DSTRule &rule, std::span buf); + } // namespace internal } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 870d689195..649cacd3f0 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -23,42 +23,6 @@ static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; -// Helper to format a DST rule for logging -#ifdef USE_TIME_TIMEZONE -static void format_dst_rule(const DSTRule &rule, char *buf, size_t buf_size) { - // Format rule part - int pos = 0; - switch (rule.type) { - case DSTRuleType::MONTH_WEEK_DAY: - pos = snprintf(buf, buf_size, "M%d.%d.%d", rule.month, rule.week, rule.day_of_week); - break; - case DSTRuleType::JULIAN_NO_LEAP: - pos = snprintf(buf, buf_size, "J%d", rule.day); - break; - case DSTRuleType::DAY_OF_YEAR: - pos = snprintf(buf, buf_size, "%d", rule.day); - break; - } - - // Format time part - int32_t time_secs = rule.time_seconds; - char sign = time_secs < 0 ? '-' : '/'; - if (time_secs < 0) - time_secs = -time_secs; - int hours = time_secs / 3600; - int mins = (time_secs % 3600) / 60; - int secs = time_secs % 60; - - if (secs != 0) { - snprintf(buf + pos, buf_size - pos, "%c%d:%02d:%02d", sign, hours, mins, secs); - } else if (mins != 0) { - snprintf(buf + pos, buf_size - pos, "%c%d:%02d", sign, hours, mins); - } else { - snprintf(buf + pos, buf_size - pos, "%c%d", sign, hours); - } -} -#endif - void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE int std_hours = -this->parsed_tz_.std_offset_seconds / 3600; @@ -67,8 +31,8 @@ void RealTimeClock::dump_config() { if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; char start_buf[24], end_buf[24]; - format_dst_rule(this->parsed_tz_.dst_start, start_buf, sizeof(start_buf)); - format_dst_rule(this->parsed_tz_.dst_end, end_buf, sizeof(end_buf)); + internal::format_dst_rule(this->parsed_tz_.dst_start, start_buf); + internal::format_dst_rule(this->parsed_tz_.dst_end, end_buf); ESP_LOGCONFIG(TAG, " DST: UTC%+d, %s - %s", dst_hours, start_buf, end_buf); } #endif diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 1a84a39835..fb3af1ae35 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -831,6 +831,90 @@ INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest, std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); +// ============================================================================ +// format_dst_rule tests +// ============================================================================ + +TEST(PosixTzParser, FormatDstRuleMonthWeekDay) { + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 2; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600; // 2:00 + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "M3.2.0/2"); + EXPECT_EQ(len, 8u); +} + +TEST(PosixTzParser, FormatDstRuleJulian) { + DSTRule rule{}; + rule.type = DSTRuleType::JULIAN_NO_LEAP; + rule.day = 60; + rule.time_seconds = 2 * 3600; + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "J60/2"); + EXPECT_EQ(len, 5u); +} + +TEST(PosixTzParser, FormatDstRuleDayOfYear) { + DSTRule rule{}; + rule.type = DSTRuleType::DAY_OF_YEAR; + rule.day = 300; + rule.time_seconds = 2 * 3600; + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "300/2"); + EXPECT_EQ(len, 5u); +} + +TEST(PosixTzParser, FormatDstRuleWithMinutes) { + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 11; + rule.week = 1; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600 + 30 * 60; // 2:30 + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "M11.1.0/2:30"); + EXPECT_EQ(len, 12u); +} + +TEST(PosixTzParser, FormatDstRuleWithSeconds) { + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 5; + rule.day_of_week = 0; + rule.time_seconds = 2 * 3600 + 30 * 60 + 45; // 2:30:45 + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "M3.5.0/2:30:45"); + EXPECT_EQ(len, 14u); +} + +TEST(PosixTzParser, FormatDstRuleNegativeTime) { + DSTRule rule{}; + rule.type = DSTRuleType::MONTH_WEEK_DAY; + rule.month = 3; + rule.week = 2; + rule.day_of_week = 0; + rule.time_seconds = -1 * 3600; // -1:00 (11 PM previous day) + + char buf[24]; + size_t len = internal::format_dst_rule(rule, buf); + EXPECT_STREQ(buf, "M3.2.0-1"); + EXPECT_EQ(len, 8u); +} + // ============================================================================ // DST boundary edge cases // ============================================================================ From 300b7169ad5ceb7bc9d5defb29ba441c108c151f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:29:10 -0600 Subject: [PATCH 24/61] cleanup --- esphome/components/time/posix_tz.cpp | 2 +- esphome/components/time/posix_tz.h | 7 +++++-- esphome/components/time/real_time_clock.cpp | 2 +- tests/components/time/posix_tz_parser.cpp | 12 ++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index d54e611443..ab1d07811b 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -342,7 +342,7 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } -size_t format_dst_rule(const DSTRule &rule, std::span buf) { +size_t format_dst_rule(const DSTRule &rule, std::span buf) { // Format rule part int pos = 0; switch (rule.type) { diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index fa2e7f8ab6..e994d53fda 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -113,11 +113,14 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs /// @return true if DST is in effect at the given time bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); +/// Buffer size for format_dst_rule output +static constexpr size_t DST_RULE_BUF_SIZE = 24; + /// Format a DST rule for logging/display /// @param rule The DST rule to format -/// @param buf Output buffer (24 bytes recommended) +/// @param buf Output buffer /// @return Number of characters written (excluding null terminator) -size_t format_dst_rule(const DSTRule &rule, std::span buf); +size_t format_dst_rule(const DSTRule &rule, std::span buf); } // namespace internal diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 649cacd3f0..025d613a0d 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -30,7 +30,7 @@ void RealTimeClock::dump_config() { ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; - char start_buf[24], end_buf[24]; + char start_buf[internal::DST_RULE_BUF_SIZE], end_buf[internal::DST_RULE_BUF_SIZE]; internal::format_dst_rule(this->parsed_tz_.dst_start, start_buf); internal::format_dst_rule(this->parsed_tz_.dst_end, end_buf); ESP_LOGCONFIG(TAG, " DST: UTC%+d, %s - %s", dst_hours, start_buf, end_buf); diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index fb3af1ae35..5311edbf27 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -843,7 +843,7 @@ TEST(PosixTzParser, FormatDstRuleMonthWeekDay) { rule.day_of_week = 0; rule.time_seconds = 2 * 3600; // 2:00 - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "M3.2.0/2"); EXPECT_EQ(len, 8u); @@ -855,7 +855,7 @@ TEST(PosixTzParser, FormatDstRuleJulian) { rule.day = 60; rule.time_seconds = 2 * 3600; - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "J60/2"); EXPECT_EQ(len, 5u); @@ -867,7 +867,7 @@ TEST(PosixTzParser, FormatDstRuleDayOfYear) { rule.day = 300; rule.time_seconds = 2 * 3600; - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "300/2"); EXPECT_EQ(len, 5u); @@ -881,7 +881,7 @@ TEST(PosixTzParser, FormatDstRuleWithMinutes) { rule.day_of_week = 0; rule.time_seconds = 2 * 3600 + 30 * 60; // 2:30 - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "M11.1.0/2:30"); EXPECT_EQ(len, 12u); @@ -895,7 +895,7 @@ TEST(PosixTzParser, FormatDstRuleWithSeconds) { rule.day_of_week = 0; rule.time_seconds = 2 * 3600 + 30 * 60 + 45; // 2:30:45 - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "M3.5.0/2:30:45"); EXPECT_EQ(len, 14u); @@ -909,7 +909,7 @@ TEST(PosixTzParser, FormatDstRuleNegativeTime) { rule.day_of_week = 0; rule.time_seconds = -1 * 3600; // -1:00 (11 PM previous day) - char buf[24]; + char buf[internal::DST_RULE_BUF_SIZE]; size_t len = internal::format_dst_rule(rule, buf); EXPECT_STREQ(buf, "M3.2.0-1"); EXPECT_EQ(len, 8u); From 64e4edd70fc170889e96f56de45ab4fb5b0f461a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:30:33 -0600 Subject: [PATCH 25/61] bad feedback from copilot --- esphome/components/time/real_time_clock.h | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 5b760d23c6..b9830ee4cb 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -27,10 +27,15 @@ class RealTimeClock : public PollingComponent { 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; it will be copied. + /// The buffer does not need to be null-terminated. void set_timezone(const char *tz, size_t len) { - std::string tz_str(tz, len); - this->apply_timezone_(tz_str.c_str()); + // Stack buffer - TZ strings are typically <64 chars + char buf[64]; + 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. From bb35e7b4b5ca85178e62e98dc4cce2f21875a42d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:31:09 -0600 Subject: [PATCH 26/61] bad feedback from copilot --- esphome/components/time/real_time_clock.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index b9830ee4cb..6e6d13f17f 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -29,8 +29,8 @@ class RealTimeClock : public PollingComponent { /// 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) { - // Stack buffer - TZ strings are typically <64 chars - char buf[64]; + // Stack buffer - TZ strings are typically short but allow up to 128 + char buf[128]; if (len >= sizeof(buf)) len = sizeof(buf) - 1; memcpy(buf, tz, len); From 899f2bbac530d93aa8c2ecc213bcdd70f27e0798 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:34:49 -0600 Subject: [PATCH 27/61] aioesphomeapi and esphome both always have M format, it was overkill --- esphome/components/time/posix_tz.cpp | 35 --------------------- esphome/components/time/posix_tz.h | 10 ------ esphome/components/time/real_time_clock.cpp | 9 +++--- 3 files changed, 5 insertions(+), 49 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index ab1d07811b..d400cce454 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -342,41 +342,6 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } -size_t format_dst_rule(const DSTRule &rule, std::span buf) { - // Format rule part - int pos = 0; - switch (rule.type) { - case DSTRuleType::MONTH_WEEK_DAY: - pos = snprintf(buf.data(), buf.size(), "M%d.%d.%d", rule.month, rule.week, rule.day_of_week); - break; - case DSTRuleType::JULIAN_NO_LEAP: - pos = snprintf(buf.data(), buf.size(), "J%d", rule.day); - break; - case DSTRuleType::DAY_OF_YEAR: - pos = snprintf(buf.data(), buf.size(), "%d", rule.day); - break; - } - - // Format time part - int32_t time_secs = rule.time_seconds; - char sign = time_secs < 0 ? '-' : '/'; - if (time_secs < 0) - time_secs = -time_secs; - int hours = time_secs / 3600; - int mins = (time_secs % 3600) / 60; - int secs = time_secs % 60; - - if (secs != 0) { - pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d:%02d:%02d", sign, hours, mins, secs); - } else if (mins != 0) { - pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d:%02d", sign, hours, mins); - } else { - pos += snprintf(buf.data() + pos, buf.size() - pos, "%c%d", sign, hours); - } - - return static_cast(pos); -} - } // namespace internal bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index e994d53fda..ea9864b304 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -2,7 +2,6 @@ #include #include -#include namespace esphome::time { @@ -113,15 +112,6 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs /// @return true if DST is in effect at the given time bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); -/// Buffer size for format_dst_rule output -static constexpr size_t DST_RULE_BUF_SIZE = 24; - -/// Format a DST rule for logging/display -/// @param rule The DST rule to format -/// @param buf Output buffer -/// @return Number of characters written (excluding null terminator) -size_t format_dst_rule(const DSTRule &rule, std::span buf); - } // namespace internal } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 025d613a0d..9dc00abfa5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -30,10 +30,11 @@ void RealTimeClock::dump_config() { ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; - char start_buf[internal::DST_RULE_BUF_SIZE], end_buf[internal::DST_RULE_BUF_SIZE]; - internal::format_dst_rule(this->parsed_tz_.dst_start, start_buf); - internal::format_dst_rule(this->parsed_tz_.dst_end, end_buf); - ESP_LOGCONFIG(TAG, " DST: UTC%+d, %s - %s", dst_hours, start_buf, end_buf); + ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, + this->parsed_tz_.dst_start.month, this->parsed_tz_.dst_start.week, + this->parsed_tz_.dst_start.day_of_week, this->parsed_tz_.dst_start.time_seconds / 3600, + this->parsed_tz_.dst_end.month, this->parsed_tz_.dst_end.week, this->parsed_tz_.dst_end.day_of_week, + this->parsed_tz_.dst_end.time_seconds / 3600); } #endif auto time = this->now(); From 77ebfc86875957eece4929e4a6007cc17b0c7ea3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:34:59 -0600 Subject: [PATCH 28/61] aioesphomeapi and esphome both always have M format, it was overkill --- esphome/components/time/real_time_clock.cpp | 1 + tests/components/time/posix_tz_parser.cpp | 84 --------------------- 2 files changed, 1 insertion(+), 84 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 9dc00abfa5..09b242d040 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -30,6 +30,7 @@ void RealTimeClock::dump_config() { ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (this->parsed_tz_.has_dst) { int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; + // Always use M format - tzdata and aioesphomeapi only generate M format rules ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, this->parsed_tz_.dst_start.month, this->parsed_tz_.dst_start.week, this->parsed_tz_.dst_start.day_of_week, this->parsed_tz_.dst_start.time_seconds / 3600, diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 5311edbf27..1a84a39835 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -831,90 +831,6 @@ INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest, std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); -// ============================================================================ -// format_dst_rule tests -// ============================================================================ - -TEST(PosixTzParser, FormatDstRuleMonthWeekDay) { - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 3; - rule.week = 2; - rule.day_of_week = 0; - rule.time_seconds = 2 * 3600; // 2:00 - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "M3.2.0/2"); - EXPECT_EQ(len, 8u); -} - -TEST(PosixTzParser, FormatDstRuleJulian) { - DSTRule rule{}; - rule.type = DSTRuleType::JULIAN_NO_LEAP; - rule.day = 60; - rule.time_seconds = 2 * 3600; - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "J60/2"); - EXPECT_EQ(len, 5u); -} - -TEST(PosixTzParser, FormatDstRuleDayOfYear) { - DSTRule rule{}; - rule.type = DSTRuleType::DAY_OF_YEAR; - rule.day = 300; - rule.time_seconds = 2 * 3600; - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "300/2"); - EXPECT_EQ(len, 5u); -} - -TEST(PosixTzParser, FormatDstRuleWithMinutes) { - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 11; - rule.week = 1; - rule.day_of_week = 0; - rule.time_seconds = 2 * 3600 + 30 * 60; // 2:30 - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "M11.1.0/2:30"); - EXPECT_EQ(len, 12u); -} - -TEST(PosixTzParser, FormatDstRuleWithSeconds) { - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 3; - rule.week = 5; - rule.day_of_week = 0; - rule.time_seconds = 2 * 3600 + 30 * 60 + 45; // 2:30:45 - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "M3.5.0/2:30:45"); - EXPECT_EQ(len, 14u); -} - -TEST(PosixTzParser, FormatDstRuleNegativeTime) { - DSTRule rule{}; - rule.type = DSTRuleType::MONTH_WEEK_DAY; - rule.month = 3; - rule.week = 2; - rule.day_of_week = 0; - rule.time_seconds = -1 * 3600; // -1:00 (11 PM previous day) - - char buf[internal::DST_RULE_BUF_SIZE]; - size_t len = internal::format_dst_rule(rule, buf); - EXPECT_STREQ(buf, "M3.2.0-1"); - EXPECT_EQ(len, 8u); -} - // ============================================================================ // DST boundary edge cases // ============================================================================ From 284a9cdab60d06d623be8b5834ac3ad554aed3d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:41:41 -0600 Subject: [PATCH 29/61] must set TZ --- esphome/components/time/real_time_clock.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 09b242d040..4792746520 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -103,7 +103,12 @@ void RealTimeClock::apply_timezone_(const char *tz) { return; } - // Parse the POSIX TZ string using our custom parser + // Set TZ env var for components using libc's localtime() directly + // (e.g., sun, datetime, wireguard, deep_sleep) + setenv("TZ", tz, 1); + tzset(); + + // Parse the POSIX TZ string using our custom parser for RealTimeClock::now() if (!parse_posix_tz(tz, this->parsed_tz_)) { ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); // Reset to UTC on parse failure From aa91cdd9840ad5c8c7be09564469c9f9a6a85361 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:47:28 -0600 Subject: [PATCH 30/61] no setz --- esphome/components/time/posix_tz.cpp | 7 ++++ esphome/components/time/posix_tz.h | 8 +++++ esphome/components/time/real_time_clock.cpp | 36 ++++++++++----------- esphome/components/time/real_time_clock.h | 3 +- esphome/core/time.h | 13 ++++++++ 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index d400cce454..315216b09c 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -3,6 +3,13 @@ namespace esphome::time { +// Global timezone for ESPTime::from_epoch_local() to use +static ParsedTimezone global_tz_{}; + +void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; } + +const ParsedTimezone &get_global_tz() { return global_tz_; } + namespace internal { // Helper to parse an unsigned integer from string, updating pointer diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index ea9864b304..27e51e7519 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -53,6 +53,14 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result); /// @return true on success bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm); +/// Set the global timezone used by epoch_to_local_tm() when called without a timezone. +/// This is called by RealTimeClock::apply_timezone_() to enable ESPTime::from_epoch_local() +/// to work without libc's localtime(). +void set_global_tz(const ParsedTimezone &tz); + +/// Get the global timezone. +const ParsedTimezone &get_global_tz(); + // Internal helper functions exposed for testing namespace internal { diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4792746520..84d611d423 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -25,17 +25,16 @@ RealTimeClock::RealTimeClock() = default; void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE - int std_hours = -this->parsed_tz_.std_offset_seconds / 3600; - int std_mins = abs(this->parsed_tz_.std_offset_seconds % 3600) / 60; + const auto &tz = get_global_tz(); + int std_hours = -tz.std_offset_seconds / 3600; + int std_mins = abs(tz.std_offset_seconds % 3600) / 60; ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); - if (this->parsed_tz_.has_dst) { - int dst_hours = -this->parsed_tz_.dst_offset_seconds / 3600; + if (tz.has_dst) { + int dst_hours = -tz.dst_offset_seconds / 3600; // Always use M format - tzdata and aioesphomeapi only generate M format rules - ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, - this->parsed_tz_.dst_start.month, this->parsed_tz_.dst_start.week, - this->parsed_tz_.dst_start.day_of_week, this->parsed_tz_.dst_start.time_seconds / 3600, - this->parsed_tz_.dst_end.month, this->parsed_tz_.dst_end.week, this->parsed_tz_.dst_end.day_of_week, - this->parsed_tz_.dst_end.time_seconds / 3600); + ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, tz.dst_start.month, + tz.dst_start.week, tz.dst_start.day_of_week, tz.dst_start.time_seconds / 3600, tz.dst_end.month, + tz.dst_end.week, tz.dst_end.day_of_week, tz.dst_end.time_seconds / 3600); } #endif auto time = this->now(); @@ -96,24 +95,23 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { #ifdef USE_TIME_TIMEZONE void RealTimeClock::apply_timezone_(const char *tz) { + ParsedTimezone parsed{}; + // Handle null input if (tz == nullptr) { ESP_LOGW(TAG, "Failed to parse timezone: (null)"); - this->parsed_tz_ = ParsedTimezone{}; + set_global_tz(parsed); return; } - // Set TZ env var for components using libc's localtime() directly - // (e.g., sun, datetime, wireguard, deep_sleep) - setenv("TZ", tz, 1); - tzset(); - - // Parse the POSIX TZ string using our custom parser for RealTimeClock::now() - if (!parse_posix_tz(tz, this->parsed_tz_)) { + // Parse the POSIX TZ string using our custom parser + if (!parse_posix_tz(tz, parsed)) { ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); - // Reset to UTC on parse failure - this->parsed_tz_ = ParsedTimezone{}; + // parsed stays as default (UTC) on failure } + + // Set global timezone for all time conversions + set_global_tz(parsed); } #endif diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 6e6d13f17f..78e99a1924 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -47,7 +47,7 @@ class RealTimeClock : public PollingComponent { #ifdef USE_TIME_TIMEZONE time_t epoch = this->timestamp_now(); struct tm local_tm; - if (epoch_to_local_tm(epoch, this->parsed_tz_, &local_tm)) { + if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) { return ESPTime::from_c_tm(&local_tm, epoch); } // Fallback to UTC if parsing failed @@ -74,7 +74,6 @@ class RealTimeClock : public PollingComponent { void synchronize_epoch_(uint32_t epoch); #ifdef USE_TIME_TIMEZONE - ParsedTimezone parsed_tz_{}; void apply_timezone_(const char *tz); #endif diff --git a/esphome/core/time.h b/esphome/core/time.h index 87ebb5c221..718477563a 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -7,6 +7,10 @@ #include #include +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif + namespace esphome { template bool increment_time_value(T ¤t, uint16_t begin, uint16_t end); @@ -105,11 +109,20 @@ struct ESPTime { * @return The generated ESPTime */ static ESPTime from_epoch_local(time_t epoch) { +#ifdef USE_TIME_TIMEZONE + struct tm local_tm; + if (time::epoch_to_local_tm(epoch, time::get_global_tz(), &local_tm)) { + return ESPTime::from_c_tm(&local_tm, epoch); + } + // Fallback to UTC if conversion failed + return ESPTime::from_epoch_utc(epoch); +#else struct tm *c_tm = ::localtime(&epoch); if (c_tm == nullptr) { return ESPTime{}; // Return an invalid ESPTime } return ESPTime::from_c_tm(c_tm, epoch); +#endif } /** Convert an UTC epoch timestamp to a UTC time ESPTime instance. * From 695df9b979f659b759f83c8ff8db214096cd59c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:49:07 -0600 Subject: [PATCH 31/61] bot review --- esphome/components/time/real_time_clock.cpp | 9 +++++---- esphome/components/time/real_time_clock.h | 4 ++++ tests/components/time/posix_tz_parser.cpp | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 84d611d423..f6478251e5 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -14,8 +14,8 @@ #include #endif #include - #include +#include namespace esphome::time { @@ -31,10 +31,11 @@ void RealTimeClock::dump_config() { ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (tz.has_dst) { int dst_hours = -tz.dst_offset_seconds / 3600; + int dst_mins = abs(tz.dst_offset_seconds % 3600) / 60; // Always use M format - tzdata and aioesphomeapi only generate M format rules - ESP_LOGCONFIG(TAG, " DST: UTC%+d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, tz.dst_start.month, - tz.dst_start.week, tz.dst_start.day_of_week, tz.dst_start.time_seconds / 3600, tz.dst_end.month, - tz.dst_end.week, tz.dst_end.day_of_week, tz.dst_end.time_seconds / 3600); + ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, dst_mins, + tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, tz.dst_start.time_seconds / 3600, + tz.dst_end.month, tz.dst_end.week, tz.dst_end.day_of_week, tz.dst_end.time_seconds / 3600); } #endif auto time = this->now(); diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 78e99a1924..c608351310 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -29,6 +29,10 @@ class RealTimeClock : public PollingComponent { /// 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 are typically short but allow up to 128 char buf[128]; if (len >= sizeof(buf)) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 1a84a39835..4a276d7b71 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -782,13 +782,14 @@ TEST_P(LibcVerificationTest, MatchesLibc) { ASSERT_TRUE(parse_posix_tz(tz_str, tz)); // Our implementation - struct tm our_tm; - epoch_to_local_tm(epoch, tz, &our_tm); + struct tm our_tm {}; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &our_tm)); // libc implementation setenv("TZ", tz_str, 1); tzset(); struct tm *libc_tm = localtime(&epoch); + ASSERT_NE(libc_tm, nullptr); EXPECT_EQ(our_tm.tm_year, libc_tm->tm_year); EXPECT_EQ(our_tm.tm_mon, libc_tm->tm_mon); From de06b36544048e5115e2d4547e10dadc21d3d4ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:50:37 -0600 Subject: [PATCH 32/61] bot review --- esphome/components/time/real_time_clock.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index f6478251e5..b99c5df125 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -27,11 +27,11 @@ void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE const auto &tz = get_global_tz(); int std_hours = -tz.std_offset_seconds / 3600; - int std_mins = abs(tz.std_offset_seconds % 3600) / 60; + int std_mins = std::abs(tz.std_offset_seconds % 3600) / 60; ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); if (tz.has_dst) { int dst_hours = -tz.dst_offset_seconds / 3600; - int dst_mins = abs(tz.dst_offset_seconds % 3600) / 60; + int dst_mins = std::abs(tz.dst_offset_seconds % 3600) / 60; // Always use M format - tzdata and aioesphomeapi only generate M format rules ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, dst_mins, tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, tz.dst_start.time_seconds / 3600, From 9e6e8a7ecbe5dfa8aedac7a765a2da0c14dafb4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:51:50 -0600 Subject: [PATCH 33/61] bot review --- esphome/core/time.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index a31b863213..053cf55772 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -271,6 +271,16 @@ void ESPTime::recalc_timestamp_local() { } int32_t ESPTime::timezone_offset() { +#ifdef USE_TIME_TIMEZONE + time_t now = ::time(nullptr); + const auto &tz = time::get_global_tz(); + // POSIX offset is positive west, but we return offset to add to UTC to get local + // So we negate the POSIX offset + if (time::internal::is_in_dst(now, tz)) { + return -tz.dst_offset_seconds; + } + return -tz.std_offset_seconds; +#else time_t now = ::time(nullptr); struct tm local_tm = *::localtime(&now); local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset. @@ -278,6 +288,7 @@ int32_t ESPTime::timezone_offset() { struct tm utc_tm = *::gmtime(&now); time_t utc_time = mktime(&utc_tm); return static_cast(local_time - utc_time); +#endif } bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; } From b2120609b94f3b502313d0bb4827be3247aab1b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:54:14 -0600 Subject: [PATCH 34/61] bot review --- esphome/core/time.cpp | 9 ++------- esphome/core/time.h | 7 ++----- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 053cf55772..f229340083 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -281,13 +281,8 @@ int32_t ESPTime::timezone_offset() { } return -tz.std_offset_seconds; #else - time_t now = ::time(nullptr); - struct tm local_tm = *::localtime(&now); - local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset. - time_t local_time = mktime(&local_tm); - struct tm utc_tm = *::gmtime(&now); - time_t utc_time = mktime(&utc_tm); - return static_cast(local_time - utc_time); + // No timezone support - no offset + return 0; #endif } diff --git a/esphome/core/time.h b/esphome/core/time.h index 718477563a..3df4d02cad 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -117,11 +117,8 @@ struct ESPTime { // Fallback to UTC if conversion failed return ESPTime::from_epoch_utc(epoch); #else - struct tm *c_tm = ::localtime(&epoch); - if (c_tm == nullptr) { - return ESPTime{}; // Return an invalid ESPTime - } - return ESPTime::from_c_tm(c_tm, epoch); + // No timezone support - return UTC (no TZ configured, localtime would return UTC anyway) + return ESPTime::from_epoch_utc(epoch); #endif } /** Convert an UTC epoch timestamp to a UTC time ESPTime instance. From c1d380dee41476974b3b8cc4ef55541ee755adaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:58:07 -0600 Subject: [PATCH 35/61] more fixes --- esphome/components/logger/logger_host.cpp | 7 +++++ esphome/core/time.cpp | 31 ++++++++++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index 874cdabd22..e956e74957 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -1,5 +1,8 @@ #if defined(USE_HOST) #include "logger.h" +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif namespace esphome::logger { @@ -11,7 +14,11 @@ void HOT Logger::write_msg_(const char *msg, size_t len) { time_t rawtime; time(&rawtime); struct tm timeinfo; +#ifdef USE_TIME_TIMEZONE + time::epoch_to_local_tm(rawtime, time::get_global_tz(), &timeinfo); +#else localtime_r(&rawtime, &timeinfo); // Thread-safe version +#endif size_t pos = strftime(buffer, TIMESTAMP_LEN + 1, "[%H:%M:%S]", &timeinfo); // Copy message (with newline already included by caller) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index f229340083..918c3c1ffb 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -257,17 +257,30 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) { } void ESPTime::recalc_timestamp_local() { - struct tm tm; +#ifdef USE_TIME_TIMEZONE + // Calculate timestamp as if fields were UTC + this->recalc_timestamp_utc(false); + if (this->timestamp == -1) { + return; // Invalid time + } - tm.tm_year = this->year - 1900; - tm.tm_mon = this->month - 1; - tm.tm_mday = this->day_of_month; - tm.tm_hour = this->hour; - tm.tm_min = this->minute; - tm.tm_sec = this->second; - tm.tm_isdst = -1; + // Now convert from local to UTC by adding the offset + // POSIX: local = utc - offset, so utc = local + offset + const auto &tz = time::get_global_tz(); - this->timestamp = mktime(&tm); + // Use standard offset as initial guess to determine DST status + time_t approx_utc = this->timestamp + tz.std_offset_seconds; + + // Check if DST is in effect and apply the appropriate offset + if (time::internal::is_in_dst(approx_utc, tz)) { + this->timestamp += tz.dst_offset_seconds; + } else { + this->timestamp += tz.std_offset_seconds; + } +#else + // No timezone support - treat as UTC + this->recalc_timestamp_utc(false); +#endif } int32_t ESPTime::timezone_offset() { From 3703755e03517e4cee4009cd9134045e8c007cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Jan 2026 23:59:39 -0600 Subject: [PATCH 36/61] more fixes --- esphome/components/time/posix_tz.cpp | 12 ++++++------ esphome/components/time/posix_tz.h | 12 ++++++------ esphome/core/time.cpp | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 315216b09c..bbceb057a0 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -327,18 +327,20 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul return days * 86400 + rule.time_seconds + base_offset_seconds; } +} // namespace internal + bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { if (!tz.has_dst) { return false; } - int year = epoch_to_year(utc_epoch); + 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); + time_t dst_start = internal::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); + time_t dst_end = internal::calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds); if (dst_start < dst_end) { // Northern hemisphere: DST is between start and end @@ -349,8 +351,6 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } -} // namespace internal - bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { if (!tz_string || !*tz_string) { return false; @@ -435,7 +435,7 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou } // Determine DST status once (avoids duplicate is_in_dst calculation) - bool in_dst = internal::is_in_dst(utc_epoch, tz); + bool in_dst = 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 27e51e7519..d44c611fe8 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -61,6 +61,12 @@ void set_global_tz(const ParsedTimezone &tz); /// Get the global timezone. const ParsedTimezone &get_global_tz(); +/// 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); + // Internal helper functions exposed for testing namespace internal { @@ -114,12 +120,6 @@ void epoch_to_tm_utc(time_t epoch, struct tm *out_tm); /// @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/esphome/core/time.cpp b/esphome/core/time.cpp index 918c3c1ffb..aa8dba4b6f 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -272,7 +272,7 @@ void ESPTime::recalc_timestamp_local() { time_t approx_utc = this->timestamp + tz.std_offset_seconds; // Check if DST is in effect and apply the appropriate offset - if (time::internal::is_in_dst(approx_utc, tz)) { + if (time::is_in_dst(approx_utc, tz)) { this->timestamp += tz.dst_offset_seconds; } else { this->timestamp += tz.std_offset_seconds; @@ -289,7 +289,7 @@ int32_t ESPTime::timezone_offset() { const auto &tz = time::get_global_tz(); // POSIX offset is positive west, but we return offset to add to UTC to get local // So we negate the POSIX offset - if (time::internal::is_in_dst(now, tz)) { + if (time::is_in_dst(now, tz)) { return -tz.dst_offset_seconds; } return -tz.std_offset_seconds; From 91ad54d864374f3c8da03c12dfd3c19119325608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:03:13 -0600 Subject: [PATCH 37/61] bot review --- esphome/components/time/posix_tz.cpp | 2 ++ esphome/core/time.cpp | 8 ++++++++ tests/components/time/posix_tz_parser.cpp | 7 +++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index bbceb057a0..1de3504072 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -274,6 +274,8 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i } // Calculate days from epoch to Jan 1 of given year +// Note: Only valid for years >= 1970. Pre-1970 timestamps are not supported +// as they are extremely rare for IoT devices. static int64_t __attribute__((noinline)) days_to_year_start(int year) { int64_t days = 0; for (int y = 1970; y < year; y++) { diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index aa8dba4b6f..e59c33dc23 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -86,6 +86,12 @@ static bool expect_char(const char *&p, const char *end, char expected) { return true; } +// Helper to skip trailing whitespace (for backward compatibility with sscanf) +static void skip_trailing_whitespace(const char *&p, const char *end) { + while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) + p++; +} + bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { // Supported formats: // YYYY-MM-DD HH:MM:SS (19 chars) @@ -150,6 +156,7 @@ bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) return false; esp_time.second = v6; + skip_trailing_whitespace(p, end); return p == end; // YYYY-MM-DD HH:MM:SS } @@ -177,6 +184,7 @@ bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) return false; esp_time.second = v3; + skip_trailing_whitespace(p, end); return p == end; // HH:MM:SS } diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 4a276d7b71..320eec821a 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -421,13 +421,12 @@ TEST(PosixTzParser, PlainDay365LeapYear) { EXPECT_EQ(day, 31); } -TEST(PosixTzParser, PlainDay365NonLeapYear) { - // Day 365 in non-leap year would be Jan 1 of next year (out of range) - // But our function should handle it gracefully +TEST(PosixTzParser, PlainDay364NonLeapYear) { + // Day 364 (0-indexed) is Dec 31 in non-leap year (last valid day) int month, day; internal::day_of_year_to_month_day(364, 2025, month, day); EXPECT_EQ(month, 12); - EXPECT_EQ(day, 31); // Day 364 is Dec 31 in non-leap year + EXPECT_EQ(day, 31); } // ============================================================================ From a757cb3c910a219127cf8863bff2d25893f0087a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:03:28 -0600 Subject: [PATCH 38/61] bot review --- esphome/components/time/real_time_clock.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index b99c5df125..0a5ff63481 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -32,10 +32,15 @@ void RealTimeClock::dump_config() { if (tz.has_dst) { int dst_hours = -tz.dst_offset_seconds / 3600; int dst_mins = std::abs(tz.dst_offset_seconds % 3600) / 60; + // Transition times (when DST starts/ends) + int start_time_hours = tz.dst_start.time_seconds / 3600; + int start_time_mins = std::abs(tz.dst_start.time_seconds % 3600) / 60; + int end_time_hours = tz.dst_end.time_seconds / 3600; + int end_time_mins = std::abs(tz.dst_end.time_seconds % 3600) / 60; // Always use M format - tzdata and aioesphomeapi only generate M format rules - ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%" PRId32 " - M%d.%d.%d/%" PRId32, dst_hours, dst_mins, - tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, tz.dst_start.time_seconds / 3600, - tz.dst_end.month, tz.dst_end.week, tz.dst_end.day_of_week, tz.dst_end.time_seconds / 3600); + ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%d:%02d - M%d.%d.%d/%d:%02d", dst_hours, dst_mins, + tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, start_time_hours, start_time_mins, + tz.dst_end.month, tz.dst_end.week, tz.dst_end.day_of_week, end_time_hours, end_time_mins); } #endif auto time = this->now(); From 31aa58c45d2f081d4f12d14d881bcc39b3b066da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:12:46 -0600 Subject: [PATCH 39/61] bot review --- tests/components/time/posix_tz_parser.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 320eec821a..44caf176af 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -641,7 +641,7 @@ TEST(PosixTzParser, IsInDstUSEasternSummer) { // July 4, 2026 12:00 UTC - definitely in DST time_t summer = make_utc(2026, 7, 4, 12); - EXPECT_TRUE(internal::is_in_dst(summer, tz)); + EXPECT_TRUE(is_in_dst(summer, tz)); } TEST(PosixTzParser, IsInDstUSEasternWinter) { @@ -650,7 +650,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(internal::is_in_dst(winter, tz)); + EXPECT_FALSE(is_in_dst(winter, tz)); } TEST(PosixTzParser, IsInDstNoDstTimezone) { @@ -659,7 +659,7 @@ TEST(PosixTzParser, IsInDstNoDstTimezone) { // July 15, 2026 12:00 UTC time_t epoch = make_utc(2026, 7, 15, 12); - EXPECT_FALSE(internal::is_in_dst(epoch, tz)); + EXPECT_FALSE(is_in_dst(epoch, tz)); } TEST(PosixTzParser, SouthernHemisphereDstSummer) { @@ -668,7 +668,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(internal::is_in_dst(nz_summer, tz)); + EXPECT_TRUE(is_in_dst(nz_summer, tz)); } TEST(PosixTzParser, SouthernHemisphereDstWinter) { @@ -677,7 +677,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(internal::is_in_dst(nz_winter, tz)); + EXPECT_FALSE(is_in_dst(nz_winter, tz)); } // ============================================================================ @@ -842,11 +842,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(internal::is_in_dst(before_epoch, tz)); + EXPECT_FALSE(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(internal::is_in_dst(after_epoch, tz)); + EXPECT_TRUE(is_in_dst(after_epoch, tz)); } TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { @@ -856,11 +856,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(internal::is_in_dst(before_epoch, tz)); + EXPECT_TRUE(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(internal::is_in_dst(after_epoch, tz)); + EXPECT_FALSE(is_in_dst(after_epoch, tz)); } } // namespace esphome::time::testing From e2b3186731380880fcc361a033f1f4b9c18ee1a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:23:09 -0600 Subject: [PATCH 40/61] remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger --- esphome/components/time/real_time_clock.cpp | 6 +++--- esphome/components/time/real_time_clock.h | 2 +- esphome/core/time.cpp | 8 -------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 0a5ff63481..f18afa7159 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -32,11 +32,11 @@ void RealTimeClock::dump_config() { if (tz.has_dst) { int dst_hours = -tz.dst_offset_seconds / 3600; int dst_mins = std::abs(tz.dst_offset_seconds % 3600) / 60; - // Transition times (when DST starts/ends) + // Transition times (when DST starts/ends) - tzdata always uses positive times (default 2:00 AM) int start_time_hours = tz.dst_start.time_seconds / 3600; - int start_time_mins = std::abs(tz.dst_start.time_seconds % 3600) / 60; + int start_time_mins = (tz.dst_start.time_seconds % 3600) / 60; int end_time_hours = tz.dst_end.time_seconds / 3600; - int end_time_mins = std::abs(tz.dst_end.time_seconds % 3600) / 60; + int end_time_mins = (tz.dst_end.time_seconds % 3600) / 60; // Always use M format - tzdata and aioesphomeapi only generate M format rules ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%d:%02d - M%d.%d.%d/%d:%02d", dst_hours, dst_mins, tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, start_time_hours, start_time_mins, diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index c608351310..9312d075df 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -33,7 +33,7 @@ class RealTimeClock : public PollingComponent { this->apply_timezone_(nullptr); return; } - // Stack buffer - TZ strings are typically short but allow up to 128 + // Stack buffer - TZ strings from tzdata are typically short (< 50 chars) char buf[128]; if (len >= sizeof(buf)) len = sizeof(buf) - 1; diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index e59c33dc23..aa8dba4b6f 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -86,12 +86,6 @@ static bool expect_char(const char *&p, const char *end, char expected) { return true; } -// Helper to skip trailing whitespace (for backward compatibility with sscanf) -static void skip_trailing_whitespace(const char *&p, const char *end) { - while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) - p++; -} - bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { // Supported formats: // YYYY-MM-DD HH:MM:SS (19 chars) @@ -156,7 +150,6 @@ bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) return false; esp_time.second = v6; - skip_trailing_whitespace(p, end); return p == end; // YYYY-MM-DD HH:MM:SS } @@ -184,7 +177,6 @@ bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) return false; esp_time.second = v3; - skip_trailing_whitespace(p, end); return p == end; // HH:MM:SS } From 6ee51b01590c51dfeff32e09f01dae419a79ad32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:25:42 -0600 Subject: [PATCH 41/61] remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger --- esphome/components/time/posix_tz.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 1de3504072..9584db568d 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -3,7 +3,7 @@ namespace esphome::time { -// Global timezone for ESPTime::from_epoch_local() to use +// Global timezone - set once at startup, rarely changes static ParsedTimezone global_tz_{}; void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; } From 22ab20ba4cc1bd7c541c6b33004e5ae11a11ca7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:36:17 -0600 Subject: [PATCH 42/61] aioesphomeapi and esphome both always have M format, it was overkill --- esphome/components/logger/logger_host.cpp | 2 +- script/cpp_unit_test.py | 1 + tests/components/time/posix_tz_parser.cpp | 129 ++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index e956e74957..6222df34f8 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -12,7 +12,7 @@ void HOT Logger::write_msg_(const char *msg, size_t len) { char buffer[TIMESTAMP_LEN + 768]; time_t rawtime; - time(&rawtime); + ::time(&rawtime); struct tm timeinfo; #ifdef USE_TIME_TIMEZONE time::epoch_to_local_tm(rawtime, time::get_global_tz(), &timeinfo); diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index e97b5bd7b0..78b65092ae 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -66,6 +66,7 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: ], "build_flags": [ "-Og", # optimize for debug + "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing ], "debug_build_flags": [ # only for debug builds "-g3", # max debug info diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 44caf176af..4a08e691b3 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -996,4 +996,133 @@ TEST(ESPTimeStrptime, LeadingZeroTime) { EXPECT_EQ(t.second, 9); } +// ============================================================================ +// recalc_timestamp_local() tests - verify behavior matches libc mktime() +// ============================================================================ + +// Helper to call libc mktime with same fields +static time_t libc_mktime(int year, int month, int day, int hour, int min, int sec) { + struct tm tm {}; + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; + tm.tm_hour = hour; + tm.tm_min = min; + tm.tm_sec = sec; + tm.tm_isdst = -1; // Let libc determine DST + return mktime(&tm); +} + +// Helper to create ESPTime and call recalc_timestamp_local +static time_t esptime_recalc_local(int year, int month, int day, int hour, int min, int sec) { + ESPTime t{}; + t.year = year; + t.month = month; + t.day_of_month = day; + t.hour = hour; + t.minute = min; + t.second = sec; + t.day_of_week = 1; // Placeholder for fields_in_range() + t.day_of_year = 1; + t.recalc_timestamp_local(); + return t.timestamp; +} + +TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) { + // Set timezone to US Central (CST6CDT) + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test a normal time in winter (no DST) + // January 15, 2026 at 10:30:00 CST + time_t libc_result = libc_mktime(2026, 1, 15, 10, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test a normal time in summer (DST active) + // July 15, 2026 at 10:30:00 CDT + libc_result = libc_mktime(2026, 7, 15, 10, 30, 0); + esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, SpringForwardSkippedHour) { + // Set timezone to US Central (CST6CDT) + // DST starts March 8, 2026 at 2:00 AM -> clocks jump to 3:00 AM + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test time before the transition (1:30 AM CST exists) + time_t libc_result = libc_mktime(2026, 3, 8, 1, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test time after the transition (3:30 AM CDT exists) + libc_result = libc_mktime(2026, 3, 8, 3, 30, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 3, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test the skipped hour (2:30 AM doesn't exist - gets normalized) + // Both implementations should produce the same result + libc_result = libc_mktime(2026, 3, 8, 2, 30, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, FallBackRepeatedHour) { + // Set timezone to US Central (CST6CDT) + // DST ends November 1, 2026 at 2:00 AM -> clocks fall back to 1:00 AM + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test time before the transition (midnight CDT) + time_t libc_result = libc_mktime(2026, 11, 1, 0, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 11, 1, 0, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test time well after the transition (3:00 AM CST) + libc_result = libc_mktime(2026, 11, 1, 3, 0, 0); + esp_result = esptime_recalc_local(2026, 11, 1, 3, 0, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test the repeated hour (1:30 AM occurs twice) + // Both implementations should resolve this the same way (typically standard time) + libc_result = libc_mktime(2026, 11, 1, 1, 30, 0); + esp_result = esptime_recalc_local(2026, 11, 1, 1, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, SouthernHemisphereDST) { + // Set timezone to Australia/Sydney (AEST-10AEDT,M10.1.0,M4.1.0) + // DST starts first Sunday of October, ends first Sunday of April + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Test winter time (July - no DST in southern hemisphere) + time_t libc_result = libc_mktime(2026, 7, 15, 10, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Test summer time (January - DST active in southern hemisphere) + libc_result = libc_mktime(2026, 1, 15, 10, 30, 0); + esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0); + EXPECT_EQ(esp_result, libc_result); +} + } // namespace esphome::testing From a93e3b6fa0e210e7402710587cf40e8e044e76ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:38:29 -0600 Subject: [PATCH 43/61] ambig time --- esphome/core/time.cpp | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index aa8dba4b6f..77a6ffb612 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -268,14 +268,33 @@ void ESPTime::recalc_timestamp_local() { // POSIX: local = utc - offset, so utc = local + offset const auto &tz = time::get_global_tz(); - // Use standard offset as initial guess to determine DST status - time_t approx_utc = this->timestamp + tz.std_offset_seconds; - - // Check if DST is in effect and apply the appropriate offset - if (time::is_in_dst(approx_utc, tz)) { - this->timestamp += tz.dst_offset_seconds; - } else { + if (!tz.has_dst) { + // No DST - just apply standard offset this->timestamp += tz.std_offset_seconds; + return; + } + + // Try both interpretations to match libc mktime() with tm_isdst=-1 + // For ambiguous times (fall-back repeated hour), libc prefers DST + // For invalid times (spring-forward skipped hour), libc normalizes to DST + time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds; + time_t utc_if_std = this->timestamp + tz.std_offset_seconds; + + bool dst_valid = time::is_in_dst(utc_if_dst, tz); + bool std_valid = !time::is_in_dst(utc_if_std, tz); + + if (dst_valid && std_valid) { + // Ambiguous time (repeated hour during fall-back) - prefer DST to match libc + this->timestamp = utc_if_dst; + } else if (dst_valid) { + // Only DST interpretation is valid + this->timestamp = utc_if_dst; + } else if (std_valid) { + // Only standard interpretation is valid + this->timestamp = utc_if_std; + } else { + // Invalid time (skipped hour during spring-forward) - use DST to match libc + this->timestamp = utc_if_dst; } #else // No timezone support - treat as UTC From 0d736e41436e39168c3a70173808e09425efa1b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 00:41:53 -0600 Subject: [PATCH 44/61] fix --- esphome/core/time.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 77a6ffb612..b1db081446 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -276,7 +276,7 @@ void ESPTime::recalc_timestamp_local() { // Try both interpretations to match libc mktime() with tm_isdst=-1 // For ambiguous times (fall-back repeated hour), libc prefers DST - // For invalid times (spring-forward skipped hour), libc normalizes to DST + // For invalid times (spring-forward skipped hour), libc normalizes forward time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds; time_t utc_if_std = this->timestamp + tz.std_offset_seconds; @@ -293,8 +293,10 @@ void ESPTime::recalc_timestamp_local() { // Only standard interpretation is valid this->timestamp = utc_if_std; } else { - // Invalid time (skipped hour during spring-forward) - use DST to match libc - this->timestamp = utc_if_dst; + // Invalid time (skipped hour during spring-forward) + // libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT + // Using std offset achieves this since the UTC result falls during DST + this->timestamp = utc_if_std; } #else // No timezone support - treat as UTC From 07a71c412d2ed5f5bff7dfbb9330c4d09e037918 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:00:07 -0600 Subject: [PATCH 45/61] make human readable --- esphome/components/time/real_time_clock.cpp | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index f18afa7159..c98dfb9b52 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -26,21 +26,11 @@ RealTimeClock::RealTimeClock() = default; void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE const auto &tz = get_global_tz(); - int std_hours = -tz.std_offset_seconds / 3600; - int std_mins = std::abs(tz.std_offset_seconds % 3600) / 60; - ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_hours, std_mins); + // POSIX offset is positive west, negate for conventional UTC+X display if (tz.has_dst) { - int dst_hours = -tz.dst_offset_seconds / 3600; - int dst_mins = std::abs(tz.dst_offset_seconds % 3600) / 60; - // Transition times (when DST starts/ends) - tzdata always uses positive times (default 2:00 AM) - int start_time_hours = tz.dst_start.time_seconds / 3600; - int start_time_mins = (tz.dst_start.time_seconds % 3600) / 60; - int end_time_hours = tz.dst_end.time_seconds / 3600; - int end_time_mins = (tz.dst_end.time_seconds % 3600) / 60; - // Always use M format - tzdata and aioesphomeapi only generate M format rules - ESP_LOGCONFIG(TAG, " DST: UTC%+d:%02d, M%d.%d.%d/%d:%02d - M%d.%d.%d/%d:%02d", dst_hours, dst_mins, - tz.dst_start.month, tz.dst_start.week, tz.dst_start.day_of_week, start_time_hours, start_time_mins, - tz.dst_end.month, tz.dst_end.week, tz.dst_end.day_of_week, end_time_hours, end_time_mins); + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d (DST UTC%+d)", -tz.std_offset_seconds / 3600, -tz.dst_offset_seconds / 3600); + } else { + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d", -tz.std_offset_seconds / 3600); } #endif auto time = this->now(); From 9628c213b5971c10bd2bc52617e145b0b635e507 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:01:21 -0600 Subject: [PATCH 46/61] make human readable --- esphome/components/time/real_time_clock.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index c98dfb9b52..4aa8e3fd71 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -27,10 +27,14 @@ void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE const auto &tz = get_global_tz(); // POSIX offset is positive west, negate for conventional UTC+X display + int std_h = -tz.std_offset_seconds / 3600; + int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60; if (tz.has_dst) { - ESP_LOGCONFIG(TAG, "Timezone: UTC%+d (DST UTC%+d)", -tz.std_offset_seconds / 3600, -tz.dst_offset_seconds / 3600); + int dst_h = -tz.dst_offset_seconds / 3600; + int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60; + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m); } else { - ESP_LOGCONFIG(TAG, "Timezone: UTC%+d", -tz.std_offset_seconds / 3600); + ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_h, std_m); } #endif auto time = this->now(); From 9b8556c2b2e220f4c8be48f6d11a1ee1bf40a303 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:03:42 -0600 Subject: [PATCH 47/61] fix --- esphome/components/logger/logger_host.cpp | 9 +-------- esphome/components/time/real_time_clock.cpp | 6 ++++++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index 6222df34f8..060e8561e7 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -1,8 +1,5 @@ #if defined(USE_HOST) #include "logger.h" -#ifdef USE_TIME_TIMEZONE -#include "esphome/components/time/posix_tz.h" -#endif namespace esphome::logger { @@ -14,11 +11,7 @@ void HOT Logger::write_msg_(const char *msg, size_t len) { time_t rawtime; ::time(&rawtime); struct tm timeinfo; -#ifdef USE_TIME_TIMEZONE - time::epoch_to_local_tm(rawtime, time::get_global_tz(), &timeinfo); -#else - localtime_r(&rawtime, &timeinfo); // Thread-safe version -#endif + localtime_r(&rawtime, &timeinfo); // TZ env var set by time component size_t pos = strftime(buffer, TIMESTAMP_LEN + 1, "[%H:%M:%S]", &timeinfo); // Copy message (with newline already included by caller) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4aa8e3fd71..f518b35951 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -104,6 +104,12 @@ void RealTimeClock::apply_timezone_(const char *tz) { 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); From 01c23eace305c1f210b11ce552b12b0c0e6d964c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:06:46 -0600 Subject: [PATCH 48/61] cleanups --- esphome/components/time/posix_tz.cpp | 6 ++++++ esphome/components/time/posix_tz.h | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 9584db568d..e57d3b3a2f 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -1,3 +1,7 @@ +#include "esphome/core/defines.h" + +#ifdef USE_TIME_TIMEZONE + #include "posix_tz.h" #include @@ -450,3 +454,5 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou } } // namespace esphome::time + +#endif // USE_TIME_TIMEZONE diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index d44c611fe8..6dbb09296e 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -1,5 +1,7 @@ #pragma once +#ifdef USE_TIME_TIMEZONE + #include #include @@ -12,14 +14,14 @@ enum class DSTRuleType : uint8_t { DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years) }; -/// Rule for DST transition +/// Rule for DST transition (packed for 32-bit: 12 bytes) struct DSTRule { + int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM) + uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) DSTRuleType type; ///< Type of rule uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY) uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY) uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) - uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) - int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM) }; /// Parsed POSIX timezone information @@ -123,3 +125,5 @@ time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offs } // namespace internal } // namespace esphome::time + +#endif // USE_TIME_TIMEZONE From f317f58545493f7421557befa349061a5eecc5e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:09:06 -0600 Subject: [PATCH 49/61] cleanups --- esphome/components/time/posix_tz.cpp | 2 +- esphome/components/time/posix_tz.h | 7 +++++-- esphome/core/time.cpp | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index e57d3b3a2f..06140599a9 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -336,7 +336,7 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul } // namespace internal bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { - if (!tz.has_dst) { + if (!tz.has_dst()) { return false; } diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index 6dbb09296e..5446ddb9df 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -9,6 +9,7 @@ namespace esphome::time { /// Type of DST transition rule enum class DSTRuleType : uint8_t { + NONE = 0, ///< No DST rule (used to indicate no DST) MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March) JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted) DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years) @@ -24,13 +25,15 @@ struct DSTRule { uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) }; -/// Parsed POSIX timezone information +/// Parsed POSIX timezone information (packed for 32-bit: 32 bytes) struct ParsedTimezone { int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west) int32_t dst_offset_seconds; ///< DST offset from UTC in seconds DSTRule dst_start; ///< When DST starts DSTRule dst_end; ///< When DST ends - bool has_dst; ///< Whether this timezone has DST + + /// Check if this timezone has DST rules + bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } }; /// Parse a POSIX TZ string into a ParsedTimezone struct. diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index b1db081446..b2f1493e2c 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -268,7 +268,7 @@ void ESPTime::recalc_timestamp_local() { // POSIX: local = utc - offset, so utc = local + offset const auto &tz = time::get_global_tz(); - if (!tz.has_dst) { + if (!tz.has_dst()) { // No DST - just apply standard offset this->timestamp += tz.std_offset_seconds; return; From 9f3e5f990ffb19e6bb3cdcf7c831e7e043e2e854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:09:30 -0600 Subject: [PATCH 50/61] cleanups --- esphome/components/time/posix_tz.cpp | 7 ++----- esphome/components/time/real_time_clock.cpp | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 06140599a9..fb98edc577 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -364,10 +364,9 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { const char *p = tz_string; - // Initialize result + // 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.has_dst = false; result.dst_start = {}; result.dst_end = {}; @@ -429,9 +428,7 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return false; } - // Only set has_dst after successfully parsing both rules - result.has_dst = true; - + // has_dst() now returns true since dst_start.type was set by parse_dst_rule return true; } diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index f518b35951..439436420f 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -29,7 +29,7 @@ void RealTimeClock::dump_config() { // POSIX offset is positive west, negate for conventional UTC+X display int std_h = -tz.std_offset_seconds / 3600; int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60; - if (tz.has_dst) { + if (tz.has_dst()) { int dst_h = -tz.dst_offset_seconds / 3600; int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60; ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m); From 31859a3eb56a0a80c29a785ff8c95f993b3e71ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:10:43 -0600 Subject: [PATCH 51/61] fix --- esphome/components/time/posix_tz.cpp | 6 ++++ tests/components/time/posix_tz_parser.cpp | 42 +++++++++++------------ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index fb98edc577..14af771075 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -324,6 +324,12 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul // Plain format: day 0-365, Feb 29 counted day_of_year_to_month_day(rule.day, year, month, day); break; + + case DSTRuleType::NONE: + // Should never be called with NONE, but handle it gracefully + month = 1; + day = 1; + break; } // Calculate days from epoch to this date diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 4a08e691b3..d491597e7d 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -31,28 +31,28 @@ TEST(PosixTzParser, ParseSimpleOffsetEST5) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("EST5", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); // +5 hours (west of UTC) - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseNegativeOffsetCET) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("CET-1", tz)); EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); // -1 hour (east of UTC) - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseExplicitPositiveOffset) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("TEST+5", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseZeroOffset) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("UTC0", tz)); EXPECT_EQ(tz.std_offset_seconds, 0); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseUSEasternWithDST) { @@ -60,7 +60,7 @@ TEST(PosixTzParser, ParseUSEasternWithDST) { ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: STD - 1hr - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.month, 3); EXPECT_EQ(tz.dst_start.week, 2); EXPECT_EQ(tz.dst_start.day_of_week, 0); // Sunday @@ -84,7 +84,7 @@ TEST(PosixTzParser, ParseEuropeBerlin) { ASSERT_TRUE(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); EXPECT_EQ(tz.dst_offset_seconds, -2 * 3600); // Default: STD - 1hr - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.month, 3); EXPECT_EQ(tz.dst_start.week, 5); // Last week EXPECT_EQ(tz.dst_end.month, 10); @@ -98,7 +98,7 @@ TEST(PosixTzParser, ParseNewZealand) { ASSERT_TRUE(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); EXPECT_EQ(tz.std_offset_seconds, -12 * 3600); EXPECT_EQ(tz.dst_offset_seconds, -13 * 3600); // Default: STD - 1hr - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.month, 9); // September EXPECT_EQ(tz.dst_end.month, 4); // April } @@ -109,7 +109,7 @@ TEST(PosixTzParser, ParseExplicitDstOffset) { ASSERT_TRUE(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); } // ============================================================================ @@ -121,7 +121,7 @@ TEST(PosixTzParser, ParseAngleBracketPositive) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("<+07>-7", tz)); EXPECT_EQ(tz.std_offset_seconds, -7 * 3600); // -7 = 7 hours east of UTC - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseAngleBracketNegative) { @@ -129,7 +129,7 @@ TEST(PosixTzParser, ParseAngleBracketNegative) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("<-03>3", tz)); EXPECT_EQ(tz.std_offset_seconds, 3 * 3600); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseAngleBracketWithDST) { @@ -138,7 +138,7 @@ TEST(PosixTzParser, ParseAngleBracketWithDST) { ASSERT_TRUE(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); EXPECT_EQ(tz.dst_offset_seconds, -11 * 3600); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.month, 10); EXPECT_EQ(tz.dst_end.month, 4); } @@ -148,7 +148,7 @@ TEST(PosixTzParser, ParseAngleBracketNamed) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("-10", tz)); EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseAngleBracketWithMinutes) { @@ -156,7 +156,7 @@ TEST(PosixTzParser, ParseAngleBracketWithMinutes) { ParsedTimezone tz; ASSERT_TRUE(parse_posix_tz("<+0545>-5:45", tz)); EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } // ============================================================================ @@ -168,7 +168,7 @@ TEST(PosixTzParser, ParseOffsetWithMinutesIndia) { // India: UTC+5:30 ASSERT_TRUE(parse_posix_tz("IST-5:30", tz)); EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 30 * 60)); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseOffsetWithMinutesNepal) { @@ -176,7 +176,7 @@ TEST(PosixTzParser, ParseOffsetWithMinutesNepal) { // Nepal: UTC+5:45 ASSERT_TRUE(parse_posix_tz("NPT-5:45", tz)); EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, ParseOffsetWithSeconds) { @@ -192,7 +192,7 @@ TEST(PosixTzParser, ParseChathamIslands) { ASSERT_TRUE(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); EXPECT_EQ(tz.std_offset_seconds, -(12 * 3600 + 45 * 60)); EXPECT_EQ(tz.dst_offset_seconds, -(13 * 3600 + 45 * 60)); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); } // ============================================================================ @@ -233,7 +233,7 @@ TEST(PosixTzParser, ParseJFormatBasic) { ParsedTimezone tz; // J format: Julian day 1-365, not counting Feb 29 ASSERT_TRUE(parse_posix_tz("EST5EDT,J60,J305", tz)); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); EXPECT_EQ(tz.dst_start.day, 60); // March 1 EXPECT_EQ(tz.dst_end.type, DSTRuleType::JULIAN_NO_LEAP); @@ -253,7 +253,7 @@ TEST(PosixTzParser, ParsePlainDayNumber) { ParsedTimezone tz; // Plain format: day 0-365, counting Feb 29 in leap years ASSERT_TRUE(parse_posix_tz("EST5EDT,59,304", tz)); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.type, DSTRuleType::DAY_OF_YEAR); EXPECT_EQ(tz.dst_start.day, 59); EXPECT_EQ(tz.dst_end.type, DSTRuleType::DAY_OF_YEAR); @@ -383,7 +383,7 @@ TEST(PosixTzParser, LowercaseMFormat) { ParsedTimezone tz; // Lowercase 'm' should be accepted ASSERT_TRUE(parse_posix_tz("EST5EDT,m3.2.0,m11.1.0", tz)); - EXPECT_TRUE(tz.has_dst); + EXPECT_TRUE(tz.has_dst()); EXPECT_EQ(tz.dst_start.month, 3); EXPECT_EQ(tz.dst_end.month, 11); } @@ -400,7 +400,7 @@ TEST(PosixTzParser, DstNameWithoutRules) { ParsedTimezone tz; // DST name present but no rules - treat as no DST since we can't determine transitions ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); } @@ -410,7 +410,7 @@ TEST(PosixTzParser, TrailingCharactersIgnored) { // This matches libc behavior ASSERT_TRUE(parse_posix_tz("EST5 extra garbage here", tz)); EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); - EXPECT_FALSE(tz.has_dst); + EXPECT_FALSE(tz.has_dst()); } TEST(PosixTzParser, PlainDay365LeapYear) { From cfea3472bd5620abf14b1183769a70d574cbc061 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:11:31 -0600 Subject: [PATCH 52/61] cleanups --- esphome/components/logger/logger_host.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index 060e8561e7..874cdabd22 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -9,9 +9,9 @@ void HOT Logger::write_msg_(const char *msg, size_t len) { char buffer[TIMESTAMP_LEN + 768]; time_t rawtime; - ::time(&rawtime); + time(&rawtime); struct tm timeinfo; - localtime_r(&rawtime, &timeinfo); // TZ env var set by time component + localtime_r(&rawtime, &timeinfo); // Thread-safe version size_t pos = strftime(buffer, TIMESTAMP_LEN + 1, "[%H:%M:%S]", &timeinfo); // Copy message (with newline already included by caller) From d31a860bf21d1cde679c52a46dfbbf34fc9948f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:18:16 -0600 Subject: [PATCH 53/61] fix, macos and linux disagree on ambig time --- esphome/core/time.cpp | 6 +++--- tests/components/time/posix_tz_parser.cpp | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index b2f1493e2c..e3db2e3e8d 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -275,7 +275,7 @@ void ESPTime::recalc_timestamp_local() { } // Try both interpretations to match libc mktime() with tm_isdst=-1 - // For ambiguous times (fall-back repeated hour), libc prefers DST + // For ambiguous times (fall-back repeated hour), prefer standard time // For invalid times (spring-forward skipped hour), libc normalizes forward time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds; time_t utc_if_std = this->timestamp + tz.std_offset_seconds; @@ -284,8 +284,8 @@ void ESPTime::recalc_timestamp_local() { bool std_valid = !time::is_in_dst(utc_if_std, tz); if (dst_valid && std_valid) { - // Ambiguous time (repeated hour during fall-back) - prefer DST to match libc - this->timestamp = utc_if_dst; + // Ambiguous time (repeated hour during fall-back) - prefer standard time + this->timestamp = utc_if_std; } else if (dst_valid) { // Only DST interpretation is valid this->timestamp = utc_if_dst; diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index d491597e7d..6a1ca016cf 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1098,10 +1098,11 @@ TEST(RecalcTimestampLocal, FallBackRepeatedHour) { EXPECT_EQ(esp_result, libc_result); // Test the repeated hour (1:30 AM occurs twice) - // Both implementations should resolve this the same way (typically standard time) - libc_result = libc_mktime(2026, 11, 1, 1, 30, 0); + // libc behavior varies by platform for this edge case, so we verify our + // consistent behavior: prefer standard time (later UTC timestamp) esp_result = esptime_recalc_local(2026, 11, 1, 1, 30, 0); - EXPECT_EQ(esp_result, libc_result); + time_t std_interpretation = esptime_recalc_local(2026, 11, 1, 2, 30, 0) - 3600; // 2:30 CST - 1 hour + EXPECT_EQ(esp_result, std_interpretation); } TEST(RecalcTimestampLocal, SouthernHemisphereDST) { From e3a99f12e4ccbdb875be7ef0a84211b8aa30bf45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:22:32 -0600 Subject: [PATCH 54/61] more edge cases --- tests/components/time/posix_tz_parser.cpp | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 6a1ca016cf..fe3fd408fc 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1126,4 +1126,78 @@ TEST(RecalcTimestampLocal, SouthernHemisphereDST) { EXPECT_EQ(esp_result, libc_result); } +TEST(RecalcTimestampLocal, ExactTransitionBoundary) { + // Test exact boundary of spring forward transition + // Mar 8, 2026 at 2:00 AM CST -> 3:00 AM CDT (clocks skip forward) + const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // 1:59:59 AM CST - last second before transition (still standard time) + time_t libc_result = libc_mktime(2026, 3, 8, 1, 59, 59); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 59, 59); + EXPECT_EQ(esp_result, libc_result); + + // 3:00:00 AM CDT - first second after transition (now DST) + libc_result = libc_mktime(2026, 3, 8, 3, 0, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 3, 0, 0); + EXPECT_EQ(esp_result, libc_result); + + // Verify the gap: 3:00 AM CDT should be exactly 1 second after 1:59:59 AM CST + time_t before_transition = esptime_recalc_local(2026, 3, 8, 1, 59, 59); + time_t after_transition = esptime_recalc_local(2026, 3, 8, 3, 0, 0); + EXPECT_EQ(after_transition - before_transition, 1); +} + +TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { + // Test DST transition at 3:00 AM instead of default 2:00 AM + // Using custom transition time: CST6CDT,M3.2.0/3,M11.1.0/3 + const char *tz_str = "CST6CDT,M3.2.0/3,M11.1.0/3"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // 2:30 AM should still be standard time (transition at 3:00 AM) + time_t libc_result = libc_mktime(2026, 3, 8, 2, 30, 0); + time_t esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // 4:00 AM should be DST (after 3:00 AM transition) + libc_result = libc_mktime(2026, 3, 8, 4, 0, 0); + esp_result = esptime_recalc_local(2026, 3, 8, 4, 0, 0); + EXPECT_EQ(esp_result, libc_result); +} + +TEST(RecalcTimestampLocal, YearBoundaryDST) { + // Test southern hemisphere DST across year boundary + // Australia/Sydney: DST active from October to April (spans Jan 1) + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Dec 31, 2025 at 23:30 - DST should be active + time_t libc_result = libc_mktime(2025, 12, 31, 23, 30, 0); + time_t esp_result = esptime_recalc_local(2025, 12, 31, 23, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Jan 1, 2026 at 00:30 - DST should still be active + libc_result = libc_mktime(2026, 1, 1, 0, 30, 0); + esp_result = esptime_recalc_local(2026, 1, 1, 0, 30, 0); + EXPECT_EQ(esp_result, libc_result); + + // Verify both are in DST (11 hour offset from UTC, not 10) + // The timestamps should be 1 hour apart + time_t dec31 = esptime_recalc_local(2025, 12, 31, 23, 30, 0); + time_t jan1 = esptime_recalc_local(2026, 1, 1, 0, 30, 0); + EXPECT_EQ(jan1 - dec31, 3600); // 1 hour difference +} + } // namespace esphome::testing From 19e9ab253e54c4bd0d6ebeb050ffc69ae162c821 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:24:48 -0600 Subject: [PATCH 55/61] cleanup --- esphome/components/time/real_time_clock.cpp | 14 ++++++++++++++ esphome/components/time/real_time_clock.h | 14 +------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 439436420f..029c0c9995 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -23,6 +23,20 @@ static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; +ESPTime __attribute__((noinline)) RealTimeClock::now() { +#ifdef USE_TIME_TIMEZONE + time_t epoch = this->timestamp_now(); + struct tm local_tm; + if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) { + return ESPTime::from_c_tm(&local_tm, epoch); + } + // Fallback to UTC if parsing failed + return ESPTime::from_epoch_utc(epoch); +#else + return ESPTime::from_epoch_local(this->timestamp_now()); +#endif +} + void RealTimeClock::dump_config() { #ifdef USE_TIME_TIMEZONE const auto &tz = get_global_tz(); diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 9312d075df..720fe593c8 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -47,19 +47,7 @@ class RealTimeClock : public PollingComponent { #endif /// Get the time in the currently defined timezone. - ESPTime now() { -#ifdef USE_TIME_TIMEZONE - time_t epoch = this->timestamp_now(); - struct tm local_tm; - if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) { - return ESPTime::from_c_tm(&local_tm, epoch); - } - // Fallback to UTC if parsing failed - return ESPTime::from_epoch_utc(epoch); -#else - return ESPTime::from_epoch_local(this->timestamp_now()); -#endif - } + ESPTime now(); /// Get the time without any time zone or DST corrections. ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } From a1eef9870ca2916dc43a2f32dc89fffd1414805d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:28:23 -0600 Subject: [PATCH 56/61] cleanup --- esphome/components/time/real_time_clock.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 029c0c9995..b7d4fc94aa 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -111,9 +111,8 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { void RealTimeClock::apply_timezone_(const char *tz) { ParsedTimezone parsed{}; - // Handle null input - if (tz == nullptr) { - ESP_LOGW(TAG, "Failed to parse timezone: (null)"); + // Handle null or empty input - use UTC + if (tz == nullptr || *tz == '\0') { set_global_tz(parsed); return; } From cde2199b6441aa05294b492fda5829df912f82c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:46:57 -0600 Subject: [PATCH 57/61] more cover --- tests/components/time/posix_tz_parser.cpp | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index fe3fd408fc..f4b0e91b9f 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1200,4 +1200,62 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) { EXPECT_EQ(jan1 - dec31, 3600); // 1 hour difference } +// ============================================================================ +// ESPTime::timezone_offset() tests +// ============================================================================ + +TEST(TimezoneOffset, NoTimezone) { + // When no timezone is set, offset should be 0 + time::ParsedTimezone tz{}; + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + EXPECT_EQ(offset, 0); +} + +TEST(TimezoneOffset, FixedOffsetPositive) { + // India: UTC+5:30 (no DST) + const char *tz_str = "IST-5:30"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + // Offset should be +5:30 = 19800 seconds (to add to UTC to get local) + EXPECT_EQ(offset, 5 * 3600 + 30 * 60); +} + +TEST(TimezoneOffset, FixedOffsetNegative) { + // US Eastern Standard Time: UTC-5 (testing without DST rules) + const char *tz_str = "EST5"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + int32_t offset = ESPTime::timezone_offset(); + // Offset should be -5 hours = -18000 seconds + EXPECT_EQ(offset, -5 * 3600); +} + +TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { + // US Eastern with DST + const char *tz_str = "EST5EDT,M3.2.0,M11.1.0"; + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Get current time and check offset matches expected based on DST status + time_t now = ::time(nullptr); + int32_t offset = ESPTime::timezone_offset(); + + // Verify offset matches what is_in_dst says + if (time::is_in_dst(now, tz)) { + // During DST, offset should be -4 hours (EDT) + EXPECT_EQ(offset, -4 * 3600); + } else { + // During standard time, offset should be -5 hours (EST) + EXPECT_EQ(offset, -5 * 3600); + } +} + } // namespace esphome::testing From b5e073bf7ffd5381c8a36cde9f73c55a3f7f45e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 01:51:20 -0600 Subject: [PATCH 58/61] clarify comment about days_to_year_start --- esphome/components/time/posix_tz.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 14af771075..af429c43bf 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -277,9 +277,9 @@ 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 -// Note: Only valid for years >= 1970. Pre-1970 timestamps are not supported -// as they are extremely rare for IoT devices. +// 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++) { From dcd0f53027e2c19786042518a9643ef8880db51a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 02:51:20 -0600 Subject: [PATCH 59/61] 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 --- esphome/components/time/posix_tz.cpp | 7 ++----- tests/components/time/posix_tz_parser.cpp | 5 +++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index af429c43bf..509643de4e 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -8,6 +8,7 @@ namespace esphome::time { // Global timezone - set once at startup, rarely changes +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state static ParsedTimezone global_tz_{}; void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; } @@ -430,12 +431,8 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return false; } p++; - if (!internal::parse_dst_rule(p, result.dst_end)) { - return false; - } - // has_dst() now returns true since dst_start.type was set by parse_dst_rule - return true; + return internal::parse_dst_rule(p, result.dst_end); } bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index f4b0e91b9f..2f41b17239 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1,6 +1,9 @@ // Tests for the POSIX TZ parser and ESPTime::strptime implementations // These custom parsers avoid pulling in scanf (~9.8KB on ESP32-IDF). +// Enable USE_TIME_TIMEZONE for tests +#define USE_TIME_TIMEZONE + #include #include #include @@ -752,6 +755,7 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { class LibcVerificationTest : public ::testing::TestWithParam> { protected: + // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name void SetUp() override { // Save current TZ const char *current_tz = getenv("TZ"); @@ -759,6 +763,7 @@ class LibcVerificationTest : public ::testing::TestWithParam Date: Fri, 30 Jan 2026 03:25:05 -0600 Subject: [PATCH 60/61] 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. --- esphome/components/time/posix_tz.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 509643de4e..bed1a3b669 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -455,4 +455,24 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou } // namespace esphome::time +// Override libc's localtime functions to use our timezone +// This allows user lambdas calling ::localtime() to get correct local time +// without needing the TZ environment variable (which pulls in scanf bloat) + +// Thread-safe version +extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) { + if (timer == nullptr || result == nullptr) { + return nullptr; + } + esphome::time::epoch_to_local_tm(*timer, esphome::time::get_global_tz(), result); + return result; +} + +// Non-thread-safe version (uses static buffer, standard libc behavior) +extern "C" struct tm *localtime(const time_t *timer) { + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static struct tm localtime_buf; + return localtime_r(timer, &localtime_buf); +} + #endif // USE_TIME_TIMEZONE From 849df4b2a8380845862d8291f80289759160c04a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Jan 2026 03:25:57 -0600 Subject: [PATCH 61/61] no host --- esphome/components/time/posix_tz.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index bed1a3b669..8c94d1d3e0 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -455,9 +455,11 @@ bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *ou } // namespace esphome::time -// Override libc's localtime functions to use our timezone +#ifndef USE_HOST +// Override libc's localtime functions to use our timezone on embedded platforms. // This allows user lambdas calling ::localtime() to get correct local time -// without needing the TZ environment variable (which pulls in scanf bloat) +// without needing the TZ environment variable (which pulls in scanf bloat). +// On host, we use the normal TZ mechanism since there's no memory constraint. // Thread-safe version extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) { @@ -474,5 +476,6 @@ extern "C" struct tm *localtime(const time_t *timer) { static struct tm localtime_buf; return localtime_r(timer, &localtime_buf); } +#endif // !USE_HOST #endif // USE_TIME_TIMEZONE