diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 4d1f0c74c2..f388267abd 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -4,6 +4,7 @@ #include "posix_tz.h" #include +#include namespace esphome::time { @@ -442,6 +443,18 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return internal::parse_dst_rule(p, result.dst_end); } +// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display. +// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822). +void format_designation(int32_t posix_offset, char *buf, size_t buf_size) { + int32_t display = -posix_offset; + char sign = display >= 0 ? '+' : '-'; + if (display < 0) + display = -display; + int h = display / 3600; + int m = (display % 3600) / 60; + snprintf(buf, buf_size, "%c%02d%02d", sign, h, m); +} + bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index c71ba15cd1..be1ddfd689 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -36,6 +36,9 @@ struct ParsedTimezone { bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } }; +/// 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). diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 650c61d37b..b6fc9b90ad 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -2,6 +2,9 @@ #include "helpers.h" #include +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif namespace esphome { @@ -14,12 +17,59 @@ uint8_t days_in_month(uint8_t month, uint16_t year) { size_t ESPTime::strftime(char *buffer, size_t buffer_len, const char *format) { struct tm c_tm = this->to_c_tm(); +#ifdef USE_TIME_TIMEZONE + // ::strftime uses libc's internal timezone state for %Z and %z, but we + // eliminated setenv("TZ")/tzset() on embedded platforms to save flash. + // Substitute %Z and %z with correct values from our parsed timezone. + // Quick scan: does format contain %Z or %z (but not %%Z/%%z)? + bool needs_subst = false; + for (const char *p = format; *p; p++) { + if (*p == '%' && *(p + 1)) { + p++; + if (*p == '%') + continue; // %% is a literal %, skip + if (*p == 'Z' || *p == 'z') { + needs_subst = true; + break; + } + } + } + if (needs_subst) { + const auto &tz = time::get_global_tz(); + char designation[6]; // "+HHMM" + null + int32_t offset = c_tm.tm_isdst > 0 ? tz.dst_offset_seconds : tz.std_offset_seconds; + time::format_designation(offset, designation, sizeof(designation)); + + char modified[STRFTIME_BUFFER_SIZE]; + char *out = modified; + char *out_end = modified + sizeof(modified) - 1; + for (const char *p = format; *p && out < out_end; p++) { + if (*p == '%') { + if (*(p + 1) == '%') { + // %% → copy both percent signs (literal %) + *out++ = *p++; + if (out < out_end) + *out++ = *p; + } else if (*(p + 1) == 'Z' || *(p + 1) == 'z') { + p++; // skip the Z/z + for (const char *d = designation; *d && out < out_end; d++) + *out++ = *d; + } else { + *out++ = *p; + } + } else { + *out++ = *p; + } + } + *out = '\0'; + return ::strftime(buffer, buffer_len, modified, &c_tm); + } +#endif return ::strftime(buffer, buffer_len, format, &c_tm); } size_t ESPTime::strftime_to(std::span buffer, const char *format) { - struct tm c_tm = this->to_c_tm(); - size_t len = ::strftime(buffer.data(), buffer.size(), format, &c_tm); + size_t len = this->strftime(buffer.data(), buffer.size(), format); if (len > 0) { return len; }