diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp new file mode 100644 index 0000000000..4d1f0c74c2 --- /dev/null +++ b/esphome/components/time/posix_tz.cpp @@ -0,0 +1,488 @@ +#include "esphome/core/defines.h" + +#ifdef USE_TIME_TIMEZONE + +#include "posix_tz.h" +#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; } + +// Convert days since epoch to year, updating days to remainder +static int __attribute__((noinline)) days_to_year(int64_t &days) { + int year = 1970; + int diy; + while (days >= (diy = days_in_year(year)) && year < 2200) { + days -= diy; + year++; + } + while (days < 0 && year > 1900) { + year--; + 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) { + 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 / 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 (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; +} + +// Calculate days from epoch to Jan 1 of given year (for DST transition calculations) +// Only supports years >= 1970. Timezone is either compiled in from YAML or set by +// Home Assistant, so pre-1970 dates are not a concern. +static int64_t __attribute__((noinline)) days_to_year_start(int year) { + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += days_in_year(y); + } + return days; +} + +time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { + int month, day; + + 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 * 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 = 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); +} + +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 diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h new file mode 100644 index 0000000000..c71ba15cd1 --- /dev/null +++ b/esphome/components/time/posix_tz.h @@ -0,0 +1,144 @@ +#pragma once + +#ifdef USE_TIME_TIMEZONE + +#include +#include + +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) +}; + +/// 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) +}; + +/// 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 + + /// 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. +/// +/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). +/// This parser only exists so that older Home Assistant clients that send the timezone +/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still +/// set the timezone on the device. Once all clients are updated to send the struct +/// directly, this function and all internal parsing helpers will be removed. +/// See https://github.com/esphome/backlog/issues/91 +/// +/// 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); + +/// 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); + +/// 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(); + +/// 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. +// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only +// used by parse_posix_tz() which is bridge code for backward compatibility. +// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.) +// are used by the conversion functions and will stay. + +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[out] month Output: month 1-12 +/// @param[out] day Output: day of month +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 +/// @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); + +/// 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); + +} // namespace internal + +} // namespace esphome::time + +#endif // USE_TIME_TIMEZONE diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 8a78186178..2e758ad8e7 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 { @@ -23,9 +23,33 @@ 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 - ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str()); + 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()) { + 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:%02d", std_h, std_m); + } #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 +96,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,9 +108,33 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { } #ifdef USE_TIME_TIMEZONE -void RealTimeClock::apply_timezone_() { - setenv("TZ", this->timezone_.c_str(), 1); +void RealTimeClock::apply_timezone_(const char *tz) { + ParsedTimezone parsed{}; + + // Handle null or empty input - use UTC + if (tz == nullptr || *tz == '\0') { + // Skip if already UTC + if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) { + return; + } + set_global_tz(parsed); + 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); + return; + } + + // 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 19aa1a4f4a..f9de5f5614 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 "posix_tz.h" +#endif namespace esphome::time { @@ -20,26 +23,31 @@ 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. + /// 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 (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) { - this->timezone_.assign(tz, len); - this->apply_timezone_(); + if (tz == nullptr) { + this->apply_timezone_(nullptr); + return; } + // Stack buffer - TZ strings from tzdata are typically short (< 50 chars) + char buf[128]; + if (len >= sizeof(buf)) + len = sizeof(buf) - 1; + memcpy(buf, tz, len); + buf[len] = '\0'; + this->apply_timezone_(buf); } - /// 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. - ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); } + ESPTime now(); /// Get the time without any time zone or DST corrections. ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } @@ -58,8 +66,7 @@ class RealTimeClock : public PollingComponent { void synchronize_epoch_(uint32_t epoch); #ifdef USE_TIME_TIMEZONE - std::string timezone_{}; - void apply_timezone_(); + void apply_timezone_(const char *tz); #endif LazyCallbackManager time_sync_callback_; diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 1aea18ac8d..73ba0a9be7 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,123 @@ 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) + + 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; + + // 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)) @@ -193,27 +257,67 @@ 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); + 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), 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; + + 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 standard time + this->timestamp = utc_if_std; + } 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) + // 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 + this->recalc_timestamp_utc(false); +#endif } int32_t ESPTime::timezone_offset() { +#ifdef USE_TIME_TIMEZONE 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); + 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::is_in_dst(now, tz)) { + return -tz.dst_offset_seconds; + } + return -tz.std_offset_seconds; +#else + // No timezone support - no offset + return 0; +#endif } bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; } diff --git a/esphome/core/time.h b/esphome/core/time.h index d9ce86131c..874f0db4b4 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,17 @@ struct ESPTime { * @return The generated ESPTime */ static ESPTime from_epoch_local(time_t epoch) { - struct tm *c_tm = ::localtime(&epoch); - if (c_tm == nullptr) { - return ESPTime{}; // Return an invalid ESPTime +#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); } - return ESPTime::from_c_tm(c_tm, epoch); + // Fallback to UTC if conversion failed + return ESPTime::from_epoch_utc(epoch); +#else + // 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. * 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 new file mode 100644 index 0000000000..d1747ef5b1 --- /dev/null +++ b/tests/components/time/posix_tz_parser.cpp @@ -0,0 +1,1275 @@ +// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime. +// +// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is +// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0. +// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely +// used to handle timezone strings from Home Assistant clients older than 2026.3.0 +// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct. +// See https://github.com/esphome/backlog/issues/91 +// +// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion +// functions that will remain permanently. + +// Enable USE_TIME_TIMEZONE for tests +#define USE_TIME_TIMEZONE + +#include +#include +#include +#include "esphome/components/time/posix_tz.h" +#include "esphome/core/time.h" + +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 +// ============================================================================ + +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)); +} + +// ============================================================================ +// 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)); +} + +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)); +} + +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 - treat as no DST since we can't determine transitions + ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); + EXPECT_FALSE(tz.has_dst()); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); +} + +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 extra garbage here", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst()); +} + +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, 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); +} + +// ============================================================================ +// 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 +// ============================================================================ + +TEST(PosixTzParser, JulianDay60IsMarch1) { + // J60 is always March 1 (J format ignores leap years by design) + int month, day; + internal::julian_to_month_day(60, month, day); + 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, month, day); + EXPECT_EQ(month, 1); + 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); + 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) { + // 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) { + 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 = internal::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 = internal::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 = internal::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 = internal::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 = internal::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 + time_t summer = make_utc(2026, 7, 4, 12); + 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 + time_t winter = make_utc(2026, 1, 15, 12); + EXPECT_FALSE(is_in_dst(winter, tz)); +} + +TEST(PosixTzParser, IsInDstNoDstTimezone) { + ParsedTimezone tz; + parse_posix_tz("IST-5:30", tz); + + // July 15, 2026 12:00 UTC + time_t epoch = make_utc(2026, 7, 15, 12); + 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 + time_t nz_summer = make_utc(2025, 12, 15, 12); + 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 + time_t nz_winter = make_utc(2026, 7, 15, 12); + 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, 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 + + // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST + time_t utc_epoch = make_utc(2026, 1, 1, 5); + + 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) + time_t utc_epoch = make_utc(2026, 7, 4, 16); + + 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> { + protected: + // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name + void SetUp() override { + // Save current TZ + const char *current_tz = getenv("TZ"); + saved_tz_ = current_tz ? current_tz : ""; + had_tz_ = current_tz != nullptr; + } + + // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name + 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(); + + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + + // Our implementation + 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); + 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) + 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) + time_t after_epoch = make_utc(2026, 3, 8, 7); + 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) + 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) + time_t after_epoch = make_utc(2026, 11, 1, 6); + EXPECT_FALSE(is_in_dst(after_epoch, tz)); +} + +} // 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, NullInputFails) { + ESPTime t{}; + EXPECT_FALSE(ESPTime::strptime(nullptr, 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); +} + +// ============================================================================ +// 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) + // 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); + 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) { + // 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); +} + +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 +} + +// ============================================================================ +// 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