diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f1bc9b003a..6ea124d155 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1002,8 +1002,12 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; - string timezone = 2; - ParsedTimezone parsed_timezone = 3; + // Deprecated in 2026.9.0: clients still send this string for older firmware, + // but new firmware only reads parsed_timezone. Clients older than Home + // Assistant 2026.3.0 that send only the string leave the device on its + // codegen-configured timezone (or UTC). + string timezone = 2 [deprecated = true]; + ParsedTimezone parsed_timezone = 3 [(track_presence) = true]; } // ==================== USER-DEFINES SERVICES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 91d13eed65..bb6c1695dd 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1204,31 +1204,28 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) - if (!value.timezone.empty()) { - // Check if the sender provided pre-parsed timezone data. - // If std_offset is non-zero or DST rules are present, the parsed data was populated. - // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent. + // Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0 + // and newer); field presence distinguishes a genuine all-zero UTC timezone from an + // absent field. Older clients send only the deprecated timezone string, which is no + // longer decoded; for them the device keeps its codegen-configured timezone. + if (value.has_parsed_timezone) { const auto &pt = value.parsed_timezone; - if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { - time::ParsedTimezone tz{}; - tz.std_offset_seconds = pt.std_offset_seconds; - tz.dst_offset_seconds = pt.dst_offset_seconds; - tz.dst_start.time_seconds = pt.dst_start.time_seconds; - tz.dst_start.day = static_cast(pt.dst_start.day); - tz.dst_start.type = static_cast(pt.dst_start.type); - tz.dst_start.month = static_cast(pt.dst_start.month); - tz.dst_start.week = static_cast(pt.dst_start.week); - tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); - tz.dst_end.time_seconds = pt.dst_end.time_seconds; - tz.dst_end.day = static_cast(pt.dst_end.day); - tz.dst_end.type = static_cast(pt.dst_end.type); - tz.dst_end.month = static_cast(pt.dst_end.month); - tz.dst_end.week = static_cast(pt.dst_end.week); - tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); - time::set_global_tz(tz); - } else { - homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); - } + time::ParsedTimezone tz{}; + tz.std_offset_seconds = pt.std_offset_seconds; + tz.dst_offset_seconds = pt.dst_offset_seconds; + tz.dst_start.time_seconds = pt.dst_start.time_seconds; + tz.dst_start.day = static_cast(pt.dst_start.day); + tz.dst_start.type = static_cast(pt.dst_start.type); + tz.dst_start.month = static_cast(pt.dst_start.month); + tz.dst_start.week = static_cast(pt.dst_start.week); + tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); + tz.dst_end.time_seconds = pt.dst_end.time_seconds; + tz.dst_end.day = static_cast(pt.dst_end.day); + tz.dst_end.type = static_cast(pt.dst_end.type); + tz.dst_end.month = static_cast(pt.dst_end.month); + tz.dst_end.week = static_cast(pt.dst_end.week); + tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); + time::set_global_tz(tz); } #endif } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index ac9c4e59cc..66295b3d53 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -116,4 +116,10 @@ extend google.protobuf.FieldOptions { // the per-byte loop when the upper bits are non-zero (the common case // for real MAC addresses, since OUIs occupy the top 24 bits). optional bool mac_address = 50019 [default=false]; + + // track_presence: Track whether this message-typed field was present on the wire. + // Generates a `bool has_{false};` member on the decoding side that is set + // to true when the field arrives, so an all-default submessage can be told apart + // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). + optional bool track_presence = 50020 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 1b8c6b05bd..33611c5ee1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1249,12 +1249,9 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } case 3: value.decode_to_message(this->parsed_timezone); + this->has_parsed_timezone = true; break; default: return false; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 8335dae1f2..13db857467 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1283,13 +1283,13 @@ class ParsedTimezone final : public ProtoDecodableMessage { class GetTimeResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 31; + static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_response"); } #endif uint32_t epoch_seconds{0}; - StringRef timezone{}; ParsedTimezone parsed_timezone{}; + bool has_parsed_timezone{false}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 4d5829e45d..d54215ba2e 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1468,7 +1468,7 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const { const char *GetTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse")); dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds); - dump_field(out, ESPHOME_PSTR("timezone"), this->timezone); + dump_field(out, ESPHOME_PSTR("has_parsed_timezone"), this->has_parsed_timezone); out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": "); this->parsed_timezone.dump_to(out); out.append("\n"); diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 35fad0a450..94ff6ab051 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -36,6 +36,7 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.helpers import cpp_string_escape _LOGGER = logging.getLogger(__name__) @@ -411,17 +412,18 @@ async def setup_time_core_(time_var, config): cg.add_define("USE_TIME_TIMEZONE") if CORE.is_host: - # Host platform needs setenv("TZ")/tzset() for libc compatibility - cg.add(time_var.set_timezone(timezone)) - else: - # Embedded: pre-parse at codegen time, emit struct directly - from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + # Host platform also needs setenv("TZ")/tzset() for libc compatibility + cg.add(cg.RawExpression(f'setenv("TZ", {cpp_string_escape(timezone)}, 1)')) + cg.add(cg.RawExpression("tzset()")) - try: - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) - except ValueError as e: - raise EsphomeError(f"Invalid timezone: {timezone}") from e + # Pre-parse at codegen time, emit struct directly + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + + try: + parsed = parse_posix_tz_python(timezone) + except ValueError as e: + raise EsphomeError(f"Invalid timezone: {timezone}") from e + _emit_parsed_timezone_fields(parsed) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index c25248e457..188df599f6 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -3,7 +3,6 @@ #ifdef USE_TIME_TIMEZONE #include "posix_tz.h" -#include #include namespace esphome::time { @@ -18,17 +17,6 @@ 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) @@ -140,62 +128,6 @@ void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) 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 @@ -236,59 +168,6 @@ void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int yea 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; @@ -373,83 +252,6 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } -// 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) { diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index be1ddfd689..249f597166 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -39,28 +39,6 @@ struct ParsedTimezone { /// Format a POSIX offset as "+HHMM"/"-HHMM" into buf (must be >= 6 bytes). void format_designation(int32_t posix_offset, char *buf, size_t buf_size); -/// 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 @@ -70,8 +48,7 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result); 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(). +/// This enables ESPTime::from_epoch_local() to work without libc's localtime(). void set_global_tz(const ParsedTimezone &tz); /// Get the global timezone. @@ -84,29 +61,9 @@ const ParsedTimezone &get_global_tz(); 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 diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 6a52348ae9..4f32560525 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,35 +107,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } -#ifdef USE_TIME_TIMEZONE -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 - } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 7a9175f39c..c449309c9f 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -15,37 +15,13 @@ namespace esphome::time { /// The RealTimeClock class exposes common timekeeping functions via the device's local real-time clock. /// /// \note -/// The C library (newlib) available on ESPs only supports TZ strings that specify an offset and DST info; -/// you cannot specify zone names or paths to zoneinfo files. -/// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html +/// The timezone is pre-parsed into a ParsedTimezone struct: at codegen time from the YAML +/// configuration, or at runtime by API clients that send the parsed struct (Home Assistant +/// 2026.3.0 and newer). See set_global_tz() in posix_tz.h. class RealTimeClock : public PollingComponent { public: explicit RealTimeClock(); -#ifdef USE_TIME_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 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 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); - } - - /// 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(); @@ -65,10 +41,6 @@ class RealTimeClock : public PollingComponent { /// Report a unix epoch as current time. void synchronize_epoch_(uint32_t epoch); -#ifdef USE_TIME_TIMEZONE - void apply_timezone_(const char *tz); -#endif - LazyCallbackManager time_sync_callback_; }; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f4eff4a254..dc3dd4b868 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -498,6 +498,15 @@ def create_field_type_info( needs_encode: bool = True, ) -> TypeInfo: """Create the appropriate TypeInfo instance for a field, handling repeated fields and custom options.""" + if get_field_opt(field, pb.track_presence, False) and ( + field.label == FieldDescriptorProto.LABEL_REPEATED + or field.type != 11 + or not needs_decode + ): + raise ValueError( + f"track_presence on field '{field.name}' has no effect; it requires " + "a non-repeated message field in a message that is decoded" + ) if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this is a packed_buffer field (zero-copy packed repeated) if get_field_opt(field, pb.packed_buffer, False): @@ -541,6 +550,8 @@ def create_field_type_info( return PointerToStringBufferType(field, None) validate_field_type(field.type, field.name) + if field.type == 11: + return MessageType(field, needs_decode, needs_encode) return TYPE_INFO[field.type](field) @@ -937,9 +948,33 @@ class MessageType(TypeInfo): # runtime polymorphism through virtual function calls. return None + @property + def public_content(self) -> list[str]: + content = [self.class_member] + if self._track_presence: + content.append(f"bool has_{self.name}{{false}};") + return content + + @property + def _track_presence(self) -> bool: + # Presence is only observable on the decode side + return self._needs_decode and get_field_opt( + self._field, pb.track_presence, False + ) + @property def decode_length_content(self) -> str: # Custom decode that doesn't use templates + if self._track_presence: + # decode_to_message() cannot report failure, so setting the flag + # afterwards only documents intent; a status-returning decode could + # gate it for real without touching callers. + return ( + f"case {self.number}:\n" + f" value.decode_to_message(this->{self.field_name});\n" + f" this->has_{self.name} = true;\n" + f" break;" + ) return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" def dump(self, name: str) -> str: @@ -947,7 +982,10 @@ class MessageType(TypeInfo): @property def dump_content(self) -> str: - o = f'out.append(2, \' \').append_p(ESPHOME_PSTR("{self.name}")).append(": ");\n' + o = "" + if self._track_presence: + o += f'dump_field(out, ESPHOME_PSTR("has_{self.name}"), this->has_{self.name});\n' + o += f'out.append(2, \' \').append_p(ESPHOME_PSTR("{self.name}")).append(": ");\n' o += f"this->{self.field_name}.dump_to(out);\n" o += 'out.append("\\n");' return o diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz.cpp similarity index 56% rename from tests/components/time/posix_tz_parser.cpp rename to tests/components/time/posix_tz.cpp index 440eea608d..760328a518 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz.cpp @@ -1,14 +1,7 @@ -// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime. +// Tests for time conversion functions, DST detection, 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. +// These tests cover the permanent timezone functions: epoch_to_local_tm, is_in_dst, +// calculate_dst_transition, and the internal helper functions they depend on. // Enable USE_TIME_TIMEZONE for tests #define USE_TIME_TIMEZONE @@ -16,6 +9,8 @@ #include #include #include +#include +#include #include "esphome/components/time/posix_tz.h" #include "esphome/core/time.h" @@ -35,435 +30,83 @@ static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0, 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()); +// Helper to build a US Eastern timezone (EST5EDT,M3.2.0/2,M11.1.0/2) +static ParsedTimezone make_us_eastern() { + ParsedTimezone tz{}; + tz.std_offset_seconds = 5 * 3600; + tz.dst_offset_seconds = 4 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 3; + tz.dst_start.week = 2; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 11; + tz.dst_end.week = 1; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 2 * 3600; + return tz; } -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()); +// Helper to build a US Central timezone (CST6CDT,M3.2.0,M11.1.0) +static ParsedTimezone make_us_central() { + ParsedTimezone tz{}; + tz.std_offset_seconds = 6 * 3600; + tz.dst_offset_seconds = 5 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 3; + tz.dst_start.week = 2; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 11; + tz.dst_end.week = 1; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 2 * 3600; + return tz; } -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()); +// Helper to build New Zealand timezone (NZST-12NZDT,M9.5.0,M4.1.0/3) +static ParsedTimezone make_new_zealand() { + ParsedTimezone tz{}; + tz.std_offset_seconds = -12 * 3600; + tz.dst_offset_seconds = -13 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 9; + tz.dst_start.week = 5; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 4; + tz.dst_end.week = 1; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 3 * 3600; + return tz; } -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 to build Australia/Sydney timezone (AEST-10AEDT,M10.1.0,M4.1.0/3) +static ParsedTimezone make_australia_sydney() { + ParsedTimezone tz{}; + tz.std_offset_seconds = -10 * 3600; + tz.dst_offset_seconds = -11 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 10; + tz.dst_start.week = 1; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 4; + tz.dst_end.week = 1; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 3 * 3600; + return tz; } // ============================================================================ // Helper function tests // ============================================================================ -TEST(PosixTzParser, JulianDay60IsMarch1) { +TEST(PosixTz, JulianDay60IsMarch1) { // J60 is always March 1 (J format ignores leap years by design) int month, day; internal::julian_to_month_day(60, month, day); @@ -471,7 +114,7 @@ TEST(PosixTzParser, JulianDay60IsMarch1) { EXPECT_EQ(day, 1); } -TEST(PosixTzParser, DayOfYear59DiffersByLeap) { +TEST(PosixTz, DayOfYear59DiffersByLeap) { int month, day; // Day 59 in leap year is Feb 29 internal::day_of_year_to_month_day(59, 2024, month, day); @@ -483,7 +126,7 @@ TEST(PosixTzParser, DayOfYear59DiffersByLeap) { EXPECT_EQ(day, 1); } -TEST(PosixTzParser, DayOfWeekKnownDates) { +TEST(PosixTz, DayOfWeekKnownDates) { // January 1, 1970 was Thursday (4) EXPECT_EQ(internal::day_of_week(1970, 1, 1), 4); // January 1, 2000 was Saturday (6) @@ -492,56 +135,56 @@ TEST(PosixTzParser, DayOfWeekKnownDates) { EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0); } -TEST(PosixTzParser, LeapYearDetection) { +TEST(PosixTz, 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) { +TEST(PosixTz, JulianDay1IsJan1) { int month, day; internal::julian_to_month_day(1, month, day); EXPECT_EQ(month, 1); EXPECT_EQ(day, 1); } -TEST(PosixTzParser, JulianDay31IsJan31) { +TEST(PosixTz, JulianDay31IsJan31) { int month, day; internal::julian_to_month_day(31, month, day); EXPECT_EQ(month, 1); EXPECT_EQ(day, 31); } -TEST(PosixTzParser, JulianDay32IsFeb1) { +TEST(PosixTz, JulianDay32IsFeb1) { int month, day; internal::julian_to_month_day(32, month, day); EXPECT_EQ(month, 2); EXPECT_EQ(day, 1); } -TEST(PosixTzParser, JulianDay59IsFeb28) { +TEST(PosixTz, JulianDay59IsFeb28) { int month, day; internal::julian_to_month_day(59, month, day); EXPECT_EQ(month, 2); EXPECT_EQ(day, 28); } -TEST(PosixTzParser, JulianDay365IsDec31) { +TEST(PosixTz, JulianDay365IsDec31) { int month, day; internal::julian_to_month_day(365, month, day); EXPECT_EQ(month, 12); EXPECT_EQ(day, 31); } -TEST(PosixTzParser, DayOfYear0IsJan1) { +TEST(PosixTz, 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(PosixTz, 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 @@ -557,19 +200,32 @@ TEST(PosixTzParser, DaysInMonthRegular) { EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case } -TEST(PosixTzParser, DaysInMonthLeapYear) { +TEST(PosixTz, DaysInMonthLeapYear) { EXPECT_EQ(internal::days_in_month(2024, 2), 29); EXPECT_EQ(internal::days_in_month(2025, 2), 28); } +TEST(PosixTz, PlainDay365LeapYear) { + int month, day; + internal::day_of_year_to_month_day(365, 2024, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTz, PlainDay364NonLeapYear) { + int month, day; + internal::day_of_year_to_month_day(364, 2025, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + // ============================================================================ // DST transition calculation tests // ============================================================================ -TEST(PosixTzParser, DstStartUSEastern2026) { +TEST(PosixTz, DstStartUSEastern2026) { // March 8, 2026 is 2nd Sunday of March - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + auto tz = make_us_eastern(); time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds); struct tm tm; @@ -582,10 +238,9 @@ TEST(PosixTzParser, DstStartUSEastern2026) { EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST } -TEST(PosixTzParser, DstEndUSEastern2026) { +TEST(PosixTz, DstEndUSEastern2026) { // November 1, 2026 is 1st Sunday of November - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + auto tz = make_us_eastern(); time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds); struct tm tm; @@ -598,7 +253,7 @@ TEST(PosixTzParser, DstEndUSEastern2026) { EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT } -TEST(PosixTzParser, LastSundayOfMarch2026) { +TEST(PosixTz, LastSundayOfMarch2026) { // Europe: M3.5.0 = last Sunday of March = March 29, 2026 DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -613,7 +268,7 @@ TEST(PosixTzParser, LastSundayOfMarch2026) { EXPECT_EQ(tm.tm_wday, 0); // Sunday } -TEST(PosixTzParser, LastSundayOfOctober2026) { +TEST(PosixTz, LastSundayOfOctober2026) { // Europe: M10.5.0 = last Sunday of October = October 25, 2026 DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -628,7 +283,7 @@ TEST(PosixTzParser, LastSundayOfOctober2026) { EXPECT_EQ(tm.tm_wday, 0); // Sunday } -TEST(PosixTzParser, FirstSundayOfApril2026) { +TEST(PosixTz, FirstSundayOfApril2026) { // April 5, 2026 is 1st Sunday DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -647,46 +302,39 @@ TEST(PosixTzParser, FirstSundayOfApril2026) { // DST detection tests // ============================================================================ -TEST(PosixTzParser, IsInDstUSEasternSummer) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - +TEST(PosixTz, IsInDstUSEasternSummer) { + auto tz = make_us_eastern(); // 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); - +TEST(PosixTz, IsInDstUSEasternWinter) { + auto tz = make_us_eastern(); // 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); +TEST(PosixTz, IsInDstNoDstTimezone) { + // India: IST-5:30 (no DST) + ParsedTimezone tz{}; + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); + // No DST rules - // 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); - +TEST(PosixTz, SouthernHemisphereDstSummer) { + auto tz = make_new_zealand(); // 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); - +TEST(PosixTz, SouthernHemisphereDstWinter) { + auto tz = make_new_zealand(); // 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)); @@ -696,9 +344,8 @@ TEST(PosixTzParser, SouthernHemisphereDstWinter) { // epoch_to_local_tm tests // ============================================================================ -TEST(PosixTzParser, EpochToLocalBasic) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalBasic) { + ParsedTimezone tz{}; // UTC time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC struct tm local; @@ -709,9 +356,8 @@ TEST(PosixTzParser, EpochToLocalBasic) { EXPECT_EQ(local.tm_hour, 0); } -TEST(PosixTzParser, EpochToLocalNegativeEpoch) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalNegativeEpoch) { + ParsedTimezone tz{}; // UTC // Dec 31, 1969 23:59:59 UTC (1 second before epoch) time_t epoch = -1; @@ -725,15 +371,15 @@ TEST(PosixTzParser, EpochToLocalNegativeEpoch) { EXPECT_EQ(local.tm_sec, 59); } -TEST(PosixTzParser, EpochToLocalNullTmFails) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalNullTmFails) { + ParsedTimezone tz{}; EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr)); } -TEST(PosixTzParser, EpochToLocalWithOffset) { - ParsedTimezone tz; - parse_posix_tz("EST5", tz); // UTC-5 +TEST(PosixTz, EpochToLocalWithOffset) { + // EST5 (UTC-5, no DST) + ParsedTimezone tz{}; + tz.std_offset_seconds = 5 * 3600; // 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); @@ -745,9 +391,8 @@ TEST(PosixTzParser, EpochToLocalWithOffset) { EXPECT_EQ(local.tm_isdst, 0); } -TEST(PosixTzParser, EpochToLocalDstTransition) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); +TEST(PosixTz, EpochToLocalDstTransition) { + auto tz = make_us_eastern(); // July 4, 2026 16:00 UTC = 12:00 EDT (noon) time_t utc_epoch = make_utc(2026, 7, 4, 16); @@ -758,210 +403,12 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { EXPECT_EQ(local.tm_isdst, 1); } -// ============================================================================ -// Leap year edge cases for closed-form year arithmetic -// ============================================================================ - -TEST(PosixTzParser, EpochToLocalLeapYear2000) { - // 2000 is a leap year (divisible by 400) - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("UTC0", tz)); - - // Feb 29, 2000 12:00:00 UTC - time_t epoch = make_utc(2000, 2, 29, 12); - struct tm local; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 100); // 2000 - EXPECT_EQ(local.tm_mon, 1); // February - EXPECT_EQ(local.tm_mday, 29); - EXPECT_EQ(local.tm_hour, 12); -} - -TEST(PosixTzParser, EpochToLocalNonLeapYear2100) { - // 2100 is NOT a leap year (divisible by 100 but not 400) - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("UTC0", tz)); - - // Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29 - time_t epoch = make_utc(2100, 3, 1); - struct tm local; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 200); // 2100 - EXPECT_EQ(local.tm_mon, 2); // March - EXPECT_EQ(local.tm_mday, 1); - - // Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29) - epoch = make_utc(2100, 2, 28, 23, 59, 59); - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 200); - EXPECT_EQ(local.tm_mon, 1); // February - EXPECT_EQ(local.tm_mday, 28); -} - -TEST(PosixTzParser, EpochToLocalLeapYear2400) { - // 2400 is a leap year (divisible by 400) - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("UTC0", tz)); - - time_t epoch = make_utc(2400, 2, 29, 6); - struct tm local; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 500); // 2400 - EXPECT_EQ(local.tm_mon, 1); // February - EXPECT_EQ(local.tm_mday, 29); - EXPECT_EQ(local.tm_hour, 6); -} - -TEST(PosixTzParser, EpochToLocalNewYearBoundaries) { - // Test year boundary — last second of 2099 and first second of 2100 - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("UTC0", tz)); - struct tm local; - - // Dec 31, 2099 23:59:59 UTC - time_t epoch = make_utc(2099, 12, 31, 23, 59, 59); - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 199); // 2099 - EXPECT_EQ(local.tm_mon, 11); // December - EXPECT_EQ(local.tm_mday, 31); - - // Jan 1, 2100 00:00:00 UTC - epoch = make_utc(2100, 1, 1); - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 200); // 2100 - EXPECT_EQ(local.tm_mon, 0); // January - EXPECT_EQ(local.tm_mday, 1); -} - -TEST(PosixTzParser, EpochToLocalDstAcrossCenturyBoundary) { - // DST transition in year 2100 (non-leap) with US Eastern rules - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz)); - - // July 4, 2100 16:00 UTC = 12:00 EDT - time_t epoch = make_utc(2100, 7, 4, 16); - struct tm local; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_hour, 12); - EXPECT_EQ(local.tm_isdst, 1); - - // Jan 15, 2100 10:00 UTC = 05:00 EST - epoch = make_utc(2100, 1, 15, 10); - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_hour, 5); - EXPECT_EQ(local.tm_isdst, 0); -} - -TEST(PosixTzParser, EpochToLocalFarFutureYear5000) { - // Year 5000 — days/365 estimate overshoots by ~2 years due to leap days, - // requiring multiple correction steps in days_to_year. - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("UTC0", tz)); - - time_t epoch = make_utc(5000, 6, 15, 12); - struct tm local; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); - EXPECT_EQ(local.tm_year, 3100); // 5000 - EXPECT_EQ(local.tm_mon, 5); // June - EXPECT_EQ(local.tm_mday, 15); - EXPECT_EQ(local.tm_hour, 12); -} - -// ============================================================================ -// 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); +TEST(PosixTz, DstBoundaryJustBeforeSpringForward) { + auto tz = make_us_eastern(); // 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); @@ -972,10 +419,8 @@ TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { 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); +TEST(PosixTz, DstBoundaryJustBeforeFallBack) { + auto tz = make_us_eastern(); // 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); @@ -1102,7 +547,6 @@ TEST(ESPTimeStrptime, PartialTimeFails) { TEST(ESPTimeStrptime, ExtraCharactersFails) { ESPTime t{}; - // Full datetime with extra characters should fail EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t)); } @@ -1123,6 +567,12 @@ TEST(ESPTimeStrptime, LeadingZeroTime) { // recalc_timestamp_local() tests - verify behavior matches libc mktime() // ============================================================================ +} // namespace esphome::testing + +namespace esphome::time::testing { + +using esphome::ESPTime; + // 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 {}; @@ -1154,8 +604,7 @@ TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) { 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)); + auto tz = make_us_central(); set_global_tz(tz); // Test a normal time in winter (no DST) @@ -1177,8 +626,7 @@ TEST(RecalcTimestampLocal, SpringForwardSkippedHour) { 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)); + auto tz = make_us_central(); set_global_tz(tz); // Test time before the transition (1:30 AM CST exists) @@ -1204,8 +652,7 @@ TEST(RecalcTimestampLocal, FallBackRepeatedHour) { 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)); + auto tz = make_us_central(); set_global_tz(tz); // Test time before the transition (midnight CDT) @@ -1227,13 +674,12 @@ TEST(RecalcTimestampLocal, FallBackRepeatedHour) { } TEST(RecalcTimestampLocal, SouthernHemisphereDST) { - // Set timezone to Australia/Sydney (AEST-10AEDT,M10.1.0,M4.1.0) + // Set timezone to Australia/Sydney (AEST-10AEDT,M10.1.0,M4.1.0/3) // DST starts first Sunday of October, ends first Sunday of April - const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0/3"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_australia_sydney(); set_global_tz(tz); // Test winter time (July - no DST in southern hemisphere) @@ -1253,8 +699,7 @@ TEST(RecalcTimestampLocal, ExactTransitionBoundary) { 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)); + auto tz = make_us_central(); set_global_tz(tz); // 1:59:59 AM CST - last second before transition (still standard time) @@ -1279,8 +724,19 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { 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)); + ParsedTimezone tz{}; + tz.std_offset_seconds = 6 * 3600; + tz.dst_offset_seconds = 5 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 3; + tz.dst_start.week = 2; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 3 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 11; + tz.dst_end.week = 1; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 3 * 3600; set_global_tz(tz); // 2:30 AM should still be standard time (transition at 3:00 AM) @@ -1298,11 +754,21 @@ TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) { // Regression test for issue #15115: DateTimeEntity::state_as_esptime() constructs // an ESPTime with only year/month/day/hour/minute/second set (no day_of_week or // day_of_year). recalc_timestamp_local() must work without those fields. - const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0"; - setenv("TZ", tz_str, 1); + setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0", 1); tzset(); time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + tz.std_offset_seconds = -1 * 3600; // CET-1 = UTC+1 + tz.dst_offset_seconds = -2 * 3600; // CEST = UTC+2 + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 3; + tz.dst_start.week = 5; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 10; + tz.dst_end.week = 5; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 2 * 3600; set_global_tz(tz); // Construct ESPTime with only date/time fields (like state_as_esptime does) @@ -1326,11 +792,11 @@ TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) { TEST(RecalcTimestampLocal, MinimalFieldsNoDST) { // Same test but with a timezone that has no DST - const char *tz_str = "IST-5:30"; - setenv("TZ", tz_str, 1); + setenv("TZ", "IST-5:30", 1); tzset(); time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); // IST-5:30 = UTC+5:30 + // No DST set_global_tz(tz); ESPTime t{}; @@ -1351,11 +817,10 @@ TEST(RecalcTimestampLocal, MinimalFieldsNoDST) { 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"; + const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0/3"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_australia_sydney(); set_global_tz(tz); // Dec 31, 2025 at 23:30 - DST should be active @@ -1380,8 +845,7 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) { // ============================================================================ TEST(TimezoneOffset, NoTimezone) { - // When no timezone is set, offset should be 0 - time::ParsedTimezone tz{}; + ParsedTimezone tz{}; set_global_tz(tz); int32_t offset = ESPTime::timezone_offset(); @@ -1389,34 +853,28 @@ TEST(TimezoneOffset, NoTimezone) { } 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)); + // India: IST-5:30 (no DST) + ParsedTimezone tz{}; + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); 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)); + // EST5 (no DST) + ParsedTimezone tz{}; + tz.std_offset_seconds = 5 * 3600; 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)); + auto tz = make_us_eastern(); set_global_tz(tz); // Get current time and check offset matches expected based on DST status @@ -1424,7 +882,7 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { int32_t offset = ESPTime::timezone_offset(); // Verify offset matches what is_in_dst says - if (time::is_in_dst(now, tz)) { + if (is_in_dst(now, tz)) { // During DST, offset should be -4 hours (EDT) EXPECT_EQ(offset, -4 * 3600); } else { @@ -1433,4 +891,234 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { } } -} // namespace esphome::testing +// ============================================================================ +// Leap year edge cases for closed-form year arithmetic +// ============================================================================ + +TEST(PosixTz, EpochToLocalLeapYear2000) { + // 2000 is a leap year (divisible by 400) + ParsedTimezone tz{}; // UTC + + // Feb 29, 2000 12:00:00 UTC + time_t epoch = make_utc(2000, 2, 29, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 100); // 2000 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 12); +} + +TEST(PosixTz, EpochToLocalNonLeapYear2100) { + // 2100 is NOT a leap year (divisible by 100 but not 400) + ParsedTimezone tz{}; // UTC + + // Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29 + time_t epoch = make_utc(2100, 3, 1); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 2); // March + EXPECT_EQ(local.tm_mday, 1); + + // Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29) + epoch = make_utc(2100, 2, 28, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 28); +} + +TEST(PosixTz, EpochToLocalLeapYear2400) { + // 2400 is a leap year (divisible by 400) + ParsedTimezone tz{}; // UTC + + time_t epoch = make_utc(2400, 2, 29, 6); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 500); // 2400 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 6); +} + +TEST(PosixTz, EpochToLocalNewYearBoundaries) { + // Test year boundary — last second of 2099 and first second of 2100 + ParsedTimezone tz{}; // UTC + struct tm local; + + // Dec 31, 2099 23:59:59 UTC + time_t epoch = make_utc(2099, 12, 31, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 199); // 2099 + EXPECT_EQ(local.tm_mon, 11); // December + EXPECT_EQ(local.tm_mday, 31); + + // Jan 1, 2100 00:00:00 UTC + epoch = make_utc(2100, 1, 1); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 0); // January + EXPECT_EQ(local.tm_mday, 1); +} + +TEST(PosixTz, EpochToLocalDstAcrossCenturyBoundary) { + // DST transition in year 2100 (non-leap) with US Eastern rules + ParsedTimezone tz = make_us_eastern(); + + // July 4, 2100 16:00 UTC = 12:00 EDT + time_t epoch = make_utc(2100, 7, 4, 16); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 12); + EXPECT_EQ(local.tm_isdst, 1); + + // Jan 15, 2100 10:00 UTC = 05:00 EST + epoch = make_utc(2100, 1, 15, 10); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 5); + EXPECT_EQ(local.tm_isdst, 0); +} + +TEST(PosixTz, EpochToLocalFarFutureYear5000) { + // Year 5000 — days/365 estimate overshoots by ~2 years due to leap days, + // requiring multiple correction steps in days_to_year. + ParsedTimezone tz{}; // UTC + + time_t epoch = make_utc(5000, 6, 15, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 3100); // 5000 + EXPECT_EQ(local.tm_mon, 5); // June + EXPECT_EQ(local.tm_mday, 15); + EXPECT_EQ(local.tm_hour, 12); +} + +// ============================================================================ +// Verification against libc +// ============================================================================ + +// Helper to build the <+07>-7 timezone (UTC+7, no DST) +static ParsedTimezone make_plus_seven() { + ParsedTimezone tz{}; + tz.std_offset_seconds = -7 * 3600; + return tz; +} + +// Helper to build the India timezone (IST-5:30, no DST) +static ParsedTimezone make_india() { + ParsedTimezone tz{}; + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); + return tz; +} + +// Helper to build the Europe/Berlin timezone (CET-1CEST,M3.5.0,M10.5.0/3) +static ParsedTimezone make_europe_berlin() { + ParsedTimezone tz{}; + tz.std_offset_seconds = -1 * 3600; + tz.dst_offset_seconds = -2 * 3600; + tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_start.month = 3; + tz.dst_start.week = 5; + tz.dst_start.day_of_week = 0; + tz.dst_start.time_seconds = 2 * 3600; + tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY; + tz.dst_end.month = 10; + tz.dst_end.week = 5; + tz.dst_end.day_of_week = 0; + tz.dst_end.time_seconds = 3 * 3600; + return tz; +} + +// Compares our converter against libc for the same zone: the struct drives +// epoch_to_local_tm() and the equivalent POSIX TZ string drives libc localtime(). +using LibcVerificationParam = std::tuple; + +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 [make_tz, tz_str, epoch] = GetParam(); + + ParsedTimezone tz = make_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(&make_us_eastern, "EST5EDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple(&make_us_eastern, "EST5EDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple(&make_us_eastern, "EST5EDT,M3.2.0/2,M11.1.0/2", + 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AngleBracket, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_plus_seven, "<+07>-7", 1704067200), + std::make_tuple(&make_plus_seven, "<+07>-7", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(India, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_india, "IST-5:30", 1704067200), + std::make_tuple(&make_india, "IST-5:30", 1720000000))); + +INSTANTIATE_TEST_SUITE_P( + NewZealand, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_new_zealand, "NZST-12NZDT,M9.5.0,M4.1.0/3", 1704067200), + std::make_tuple(&make_new_zealand, "NZST-12NZDT,M9.5.0,M4.1.0/3", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(USCentral, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_us_central, "CST6CDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple(&make_us_central, "CST6CDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple(&make_us_central, "CST6CDT,M3.2.0/2,M11.1.0/2", + 1735689600))); + +INSTANTIATE_TEST_SUITE_P( + EuropeBerlin, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_europe_berlin, "CET-1CEST,M3.5.0,M10.5.0/3", 1704067200), + std::make_tuple(&make_europe_berlin, "CET-1CEST,M3.5.0,M10.5.0/3", 1720000000), + std::make_tuple(&make_europe_berlin, "CET-1CEST,M3.5.0,M10.5.0/3", 1735689600))); + +INSTANTIATE_TEST_SUITE_P( + AustraliaSydney, LibcVerificationTest, + ::testing::Values(std::make_tuple(&make_australia_sydney, "AEST-10AEDT,M10.1.0,M4.1.0/3", 1704067200), + std::make_tuple(&make_australia_sydney, "AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), + std::make_tuple(&make_australia_sydney, "AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); + +} // namespace esphome::time::testing diff --git a/tests/components/time/test.host.yaml b/tests/components/time/test.host.yaml new file mode 100644 index 0000000000..f6bec9fd1d --- /dev/null +++ b/tests/components/time/test.host.yaml @@ -0,0 +1,10 @@ +network: + +api: + +time: + - platform: homeassistant + # Angle-bracket name pins the explicit-timezone host codegen path + # (setenv/tzset plus pre-parsed struct emission) with characters that + # would break unescaped string interpolation. + timezone: "<+07>-7" diff --git a/tests/integration/fixtures/api_get_time_response_timezone.yaml b/tests/integration/fixtures/api_get_time_response_timezone.yaml new file mode 100644 index 0000000000..bece0c5684 --- /dev/null +++ b/tests/integration/fixtures/api_get_time_response_timezone.yaml @@ -0,0 +1,20 @@ +esphome: + name: get-time-tz-test +host: +api: +logger: + +time: + - platform: homeassistant + id: ha_time + +sensor: + # Exposes the standard offset of the effective timezone so the test can + # observe which GetTimeResponse messages changed it + - platform: template + name: "TZ Offset" + id: tz_offset + accuracy_decimals: 0 + update_interval: 100ms + lambda: |- + return time::get_global_tz().std_offset_seconds; diff --git a/tests/integration/test_api_get_time_response_timezone.py b/tests/integration/test_api_get_time_response_timezone.py new file mode 100644 index 0000000000..c90380607f --- /dev/null +++ b/tests/integration/test_api_get_time_response_timezone.py @@ -0,0 +1,67 @@ +"""Integration test for GetTimeResponse parsed_timezone presence handling.""" + +from __future__ import annotations + +from aioesphomeapi import connection as api_connection +from aioesphomeapi.api_pb2 import GetTimeResponse +import pytest + +from .state_utils import SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# 2024-01-01 00:00:00 UTC +EPOCH = 1704067200 +# POSIX offsets are positive west of UTC, so UTC+7 is -25200 and UTC-5 is 18000 +UTC_PLUS_7 = -25200 +UTC_MINUS_5 = 18000 + + +@pytest.mark.asyncio +async def test_api_get_time_response_timezone( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A present parsed_timezone is applied even when all zero; an absent one is ignored.""" + # The client answers the device's own GetTimeRequest with the host timezone; + # strip the parsed field from that reply so only the messages sent below + # can change the device timezone. + monkeypatch.setattr(api_connection, "_build_parsed_tz_proto", lambda tz: None) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + tracker = SensorTracker(["tz_offset"]) + tracker.key_to_sensor = build_key_to_entity_mapping(entities, ["tz_offset"]) + client.subscribe_states(tracker.on_state) + + await tracker.await_change(tracker.expect_any("tz_offset"), "tz_offset") + initial = tracker.sensor_states["tz_offset"][-1] + # Pick a zone that differs from the codegen default so the change is visible + target = UTC_PLUS_7 if initial != UTC_PLUS_7 else UTC_MINUS_5 + + # Present, non-zero: applied + future = tracker.expect("tz_offset", target) + resp = GetTimeResponse(epoch_seconds=EPOCH) + resp.parsed_timezone.std_offset_seconds = target + resp.parsed_timezone.dst_offset_seconds = target + client._connection.send_messages((resp,)) + await tracker.await_change(future, "tz_offset") + + # Absent (legacy client with only the deprecated string): ignored, and in + # particular not mistaken for an all-zero UTC zone + future = tracker.expect("tz_offset", 0) + resp = GetTimeResponse(epoch_seconds=EPOCH, timezone="UTC0") + client._connection.send_messages((resp,)) + await tracker.await_must_not_change(future, "tz_offset", timeout=1.0) + assert tracker.sensor_states["tz_offset"][-1] == target + # Retire the expectation so it cannot swallow the first matching state + # meant for the next phase + future.cancel() + + # Present but all zero (genuine UTC): applied + future = tracker.expect("tz_offset", 0) + resp = GetTimeResponse(epoch_seconds=EPOCH) + resp.parsed_timezone.SetInParent() + client._connection.send_messages((resp,)) + await tracker.await_change(future, "tz_offset")