mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 01:58:39 +00:00
Merge remote-tracking branch 'upstream/fix-strftime-tz-designation' into integration
# Conflicts: # esphome/components/time/posix_tz.cpp # esphome/components/time/posix_tz.h
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
|
||||
#include "posix_tz.h"
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
@@ -16,6 +18,17 @@ 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<unsigned char>(*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)
|
||||
@@ -109,6 +122,62 @@ 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>, <AEST>
|
||||
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<unsigned char>(*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
|
||||
@@ -149,6 +218,59 @@ 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<unsigned char>(*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;
|
||||
@@ -244,6 +366,95 @@ 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<unsigned char>(*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<unsigned char>(*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<unsigned char>(*p)) || *p == '+' || *p == '-')) {
|
||||
result.dst_offset_seconds = internal::parse_offset(p);
|
||||
} else {
|
||||
result.dst_offset_seconds = result.std_offset_seconds - 3600;
|
||||
}
|
||||
|
||||
// Parse DST rules (required when DST name is present)
|
||||
if (*p != ',') {
|
||||
// DST name without rules - treat as no DST since we can't determine transitions
|
||||
return true;
|
||||
}
|
||||
|
||||
p++;
|
||||
if (!internal::parse_dst_rule(p, result.dst_start)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Second rule is required per POSIX
|
||||
if (*p != ',') {
|
||||
return false;
|
||||
}
|
||||
p++;
|
||||
// has_dst() now returns true since dst_start.type was set by parse_dst_rule
|
||||
return internal::parse_dst_rule(p, result.dst_end);
|
||||
}
|
||||
|
||||
// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display.
|
||||
// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822).
|
||||
void format_designation(int32_t posix_offset, char *buf, size_t buf_size) {
|
||||
int32_t display = -posix_offset;
|
||||
char sign = display >= 0 ? '+' : '-';
|
||||
if (display < 0)
|
||||
display = -display;
|
||||
int h = display / 3600;
|
||||
int m = (display % 3600) / 60;
|
||||
snprintf(buf, buf_size, "%c%02d%02d", sign, h, m);
|
||||
}
|
||||
|
||||
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) {
|
||||
if (!out_tm) {
|
||||
return false;
|
||||
|
||||
@@ -36,6 +36,31 @@ 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).
|
||||
/// 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
|
||||
@@ -59,9 +84,29 @@ 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
|
||||
|
||||
+52
-2
@@ -2,6 +2,9 @@
|
||||
#include "helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#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<char, STRFTIME_BUFFER_SIZE> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user