#include "esphome/core/defines.h" #ifdef USE_TIME_TIMEZONE #include "posix_tz.h" #include #include 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; } const ParsedTimezone &get_global_tz() { return global_tz_; } namespace internal { // Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule, // and parse_transition_time are only used by parse_posix_tz() (bridge code). static uint32_t parse_uint(const char *&p) { uint32_t value = 0; while (std::isdigit(static_cast(*p))) { value = value * 10 + (*p - '0'); p++; } return value; } bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } // Get days in year (avoids duplicate is_leap_year calls) static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; } // Count leap years in [1, year] (i.e. up to and including year) static constexpr int count_leap_years_up_to(int year) { return year / 4 - year / 100 + year / 400; } constexpr int EPOCH_YEAR = 1970; constexpr int LEAP_YEARS_BEFORE_EPOCH = count_leap_years_up_to(EPOCH_YEAR - 1); constexpr int DAYS_PER_YEAR = 365; constexpr int SECONDS_PER_DAY = 86400; // Days from epoch (Jan 1 1970) to Jan 1 of given year — O(1) static inline int64_t days_to_year_start(int year) { return static_cast(DAYS_PER_YEAR) * (year - EPOCH_YEAR) + (count_leap_years_up_to(year - 1) - LEAP_YEARS_BEFORE_EPOCH); } // Convert days since epoch to year, updating days to day-of-year remainder. // The initial estimate from days/365 can overshoot by multiple years for // far-future dates (e.g., year 5000+) due to accumulated leap days, // so we use loops rather than single-step correction. static int days_to_year(int64_t &days) { int year = static_cast(EPOCH_YEAR + days / DAYS_PER_YEAR); int64_t year_start = days_to_year_start(year); while (days < year_start) { year--; year_start = days_to_year_start(year); } while (days >= year_start + days_in_year(year)) { year_start += days_in_year(year); year++; } days -= year_start; return year; } // Extract just the year from a UTC epoch — O(1) static int epoch_to_year(time_t epoch) { int64_t days = epoch / SECONDS_PER_DAY; if (epoch < 0 && epoch % SECONDS_PER_DAY != 0) days--; return days_to_year(days); } int days_in_month(int year, int month) { 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 __attribute__((noinline)) 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 __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { // Days since epoch int64_t days = epoch / SECONDS_PER_DAY; int32_t remaining_secs = epoch % SECONDS_PER_DAY; if (remaining_secs < 0) { days--; remaining_secs += SECONDS_PER_DAY; } 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 (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; int dim; while (days >= (dim = days_in_month(year, month))) { days -= dim; month++; } out_tm->tm_mon = month - 1; out_tm->tm_mday = static_cast(days) + 1; out_tm->tm_isdst = 0; } 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 __attribute__((noinline)) parse_offset(const char *&p) { int sign = 1; if (*p == '-') { sign = -1; p++; } else if (*p == '+') { p++; } int hours = parse_uint(p); int minutes = 0; int seconds = 0; if (*p == ':') { p++; minutes = parse_uint(p); if (*p == ':') { p++; seconds = parse_uint(p); } } return sign * (hours * 3600 + minutes * 60 + seconds); } // Helper to parse the optional /time suffix (reuses parse_offset logic) static void parse_transition_time(const char *&p, DSTRule &rule) { rule.time_seconds = 2 * 3600; // Default 02:00 if (*p == '/') { p++; rule.time_seconds = parse_offset(p); } } void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) { // J format: day 1-365, Feb 29 is NOT counted even in leap years // So day 60 is always March 1 // Iterate forward through months (no array needed) int remaining = julian_day; out_month = 1; 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 = remaining; } 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; 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) { rule = {}; // Zero initialize if (*p == 'M' || *p == 'm') { // M format: Mm.w.d (month.week.day) rule.type = DSTRuleType::MONTH_WEEK_DAY; p++; rule.month = parse_uint(p); if (rule.month < 1 || rule.month > 12) return false; if (*p++ != '.') return false; rule.week = parse_uint(p); if (rule.week < 1 || rule.week > 5) return false; if (*p++ != '.') return false; rule.day_of_week = parse_uint(p); if (rule.day_of_week > 6) return false; } else if (*p == 'J' || *p == 'j') { // J format: Jn (Julian day 1-365, not counting Feb 29) rule.type = DSTRuleType::JULIAN_NO_LEAP; p++; rule.day = parse_uint(p); if (rule.day < 1 || rule.day > 365) return false; } else if (std::isdigit(static_cast(*p))) { // Plain number format: n (day 0-365, counting Feb 29) rule.type = DSTRuleType::DAY_OF_YEAR; rule.day = parse_uint(p); if (rule.day > 365) return false; } else { return false; } // Parse optional /time suffix parse_transition_time(p, rule); return true; } // Calculate days from Jan 1 of given year to given month/day static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) { int days = day - 1; for (int m = 1; m < month; m++) { days += days_in_month(year, m); } 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_dow = day_of_week(year, rule.month, 1); // Days until first occurrence of target day 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 dim = days_in_month(year, rule.month); day = first_occurrence; while (day + 7 <= dim) { 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; 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 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 * SECONDS_PER_DAY + 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 = 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 = internal::calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds); // DST end transition happens in daylight time 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 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); } } // Remove before 2026.9.0: This parser is bridge code for backward compatibility with // older Home Assistant clients that send the timezone as a POSIX TZ string instead of // the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct // directly, this function and the parsing helpers above (skip_tz_name, parse_offset, // parse_dst_rule, parse_transition_time) can be removed. // See https://github.com/esphome/backlog/issues/91 bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { if (!tz_string || !*tz_string) { return false; } const char *p = tz_string; // Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false) result.std_offset_seconds = 0; result.dst_offset_seconds = 0; result.dst_start = {}; result.dst_end = {}; // Skip standard timezone name if (!internal::skip_tz_name(p)) { return false; } // Parse standard offset (required) if (!*p || (!std::isdigit(static_cast(*p)) && *p != '+' && *p != '-')) { return false; } result.std_offset_seconds = internal::parse_offset(p); // Check for DST name if (!*p) { return true; // No DST } // If next char is comma, there's no DST name but there are rules (invalid) if (*p == ',') { return false; } // Check if there's something that looks like a DST name start // (letter or angle bracket). If not, treat as trailing garbage and return success. if (!std::isalpha(static_cast(*p)) && *p != '<') { return true; // No DST, trailing characters ignored } if (!internal::skip_tz_name(p)) { return false; // Invalid DST name (started but malformed) } // Optional DST offset (default is std - 1 hour) if (*p && *p != ',' && (std::isdigit(static_cast(*p)) || *p == '+' || *p == '-')) { result.dst_offset_seconds = internal::parse_offset(p); } else { result.dst_offset_seconds = result.std_offset_seconds - 3600; } // Parse DST rules (required when DST name is present) if (*p != ',') { // DST name without rules - treat as no DST since we can't determine transitions return true; } p++; if (!internal::parse_dst_rule(p, result.dst_start)) { return false; } // Second rule is required per POSIX if (*p != ',') { return false; } p++; // has_dst() now returns true since dst_start.type was set by parse_dst_rule return internal::parse_dst_rule(p, result.dst_end); } // Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display. // Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822). void format_designation(int32_t posix_offset, char *buf, size_t buf_size) { int32_t display = -posix_offset; char sign = display >= 0 ? '+' : '-'; if (display < 0) display = -display; int h = display / 3600; int m = (display % 3600) / 60; snprintf(buf, buf_size, "%c%02d%02d", sign, h, m); } 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); 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 = in_dst ? 1 : 0; return true; } } // namespace esphome::time #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). // 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) { 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_HOST #endif // USE_TIME_TIMEZONE