ESPHome 2026.5.0-dev
Loading...
Searching...
No Matches
posix_tz.cpp
Go to the documentation of this file.
2
3#ifdef USE_TIME_TIMEZONE
4
5#include "posix_tz.h"
6#include <cctype>
7#include <cstdio>
8
9namespace esphome::time {
10
11// Global timezone - set once at startup, rarely changes
12// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state
13static ParsedTimezone global_tz_{};
14
15void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; }
16
17const ParsedTimezone &get_global_tz() { return global_tz_; }
18
19namespace internal {
20
21// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule,
22// and parse_transition_time are only used by parse_posix_tz() (bridge code).
23static uint32_t parse_uint(const char *&p) {
24 uint32_t value = 0;
25 while (std::isdigit(static_cast<unsigned char>(*p))) {
26 value = value * 10 + (*p - '0');
27 p++;
28 }
29 return value;
30}
31
32bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }
33
34// Get days in year (avoids duplicate is_leap_year calls)
35static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; }
36
37// Count leap years in [1, year] (i.e. up to and including year)
38static constexpr int count_leap_years_up_to(int year) { return year / 4 - year / 100 + year / 400; }
39
40constexpr int EPOCH_YEAR = 1970;
41constexpr int LEAP_YEARS_BEFORE_EPOCH = count_leap_years_up_to(EPOCH_YEAR - 1);
42constexpr int DAYS_PER_YEAR = 365;
43constexpr int SECONDS_PER_DAY = 86400;
44
45// Days from epoch (Jan 1 1970) to Jan 1 of given year — O(1)
46static inline int64_t days_to_year_start(int year) {
47 return static_cast<int64_t>(DAYS_PER_YEAR) * (year - EPOCH_YEAR) +
48 (count_leap_years_up_to(year - 1) - LEAP_YEARS_BEFORE_EPOCH);
49}
50
51// Convert days since epoch to year, updating days to day-of-year remainder.
52// The initial estimate from days/365 can overshoot by multiple years for
53// far-future dates (e.g., year 5000+) due to accumulated leap days,
54// so we use loops rather than single-step correction.
55static int days_to_year(int64_t &days) {
56 int year = static_cast<int>(EPOCH_YEAR + days / DAYS_PER_YEAR);
57 int64_t year_start = days_to_year_start(year);
58 while (days < year_start) {
59 year--;
60 year_start = days_to_year_start(year);
61 }
62 while (days >= year_start + days_in_year(year)) {
63 year_start += days_in_year(year);
64 year++;
65 }
66 days -= year_start;
67 return year;
68}
69
70// Extract just the year from a UTC epoch — O(1)
71static int epoch_to_year(time_t epoch) {
72 int64_t days = epoch / SECONDS_PER_DAY;
73 if (epoch < 0 && epoch % SECONDS_PER_DAY != 0)
74 days--;
75 return days_to_year(days);
76}
77
78int days_in_month(int year, int month) {
79 switch (month) {
80 case 2:
81 return is_leap_year(year) ? 29 : 28;
82 case 4:
83 case 6:
84 case 9:
85 case 11:
86 return 30;
87 default:
88 return 31;
89 }
90}
91
92// Zeller-like algorithm for day of week (0 = Sunday)
93int __attribute__((noinline)) day_of_week(int year, int month, int day) {
94 // Adjust for January/February
95 if (month < 3) {
96 month += 12;
97 year--;
98 }
99 int k = year % 100;
100 int j = year / 100;
101 int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
102 // Convert from Zeller (0=Sat) to standard (0=Sun)
103 return ((h + 6) % 7);
104}
105
106void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) {
107 // Days since epoch
108 int64_t days = epoch / SECONDS_PER_DAY;
110 if (remaining_secs < 0) {
111 days--;
113 }
114
115 out_tm->tm_sec = remaining_secs % 60;
116 remaining_secs /= 60;
117 out_tm->tm_min = remaining_secs % 60;
118 out_tm->tm_hour = remaining_secs / 60;
119
120 // Day of week (Jan 1, 1970 was Thursday = 4)
121 out_tm->tm_wday = static_cast<int>((days + 4) % 7);
122 if (out_tm->tm_wday < 0)
123 out_tm->tm_wday += 7;
124
125 // Calculate year (updates days to day-of-year)
126 int year = days_to_year(days);
127 out_tm->tm_year = year - 1900;
128 out_tm->tm_yday = static_cast<int>(days);
129
130 // Calculate month and day
131 int month = 1;
132 int dim;
133 while (days >= (dim = days_in_month(year, month))) {
134 days -= dim;
135 month++;
136 }
137
138 out_tm->tm_mon = month - 1;
139 out_tm->tm_mday = static_cast<int>(days) + 1;
140 out_tm->tm_isdst = 0;
141}
142
143bool skip_tz_name(const char *&p) {
144 if (*p == '<') {
145 // Angle-bracket quoted name: <+07>, <-03>, <AEST>
146 p++; // skip '<'
147 while (*p && *p != '>') {
148 p++;
149 }
150 if (*p == '>') {
151 p++; // skip '>'
152 return true;
153 }
154 return false; // Unterminated
155 }
156
157 // Standard name: 3+ letters
158 const char *start = p;
159 while (*p && std::isalpha(static_cast<unsigned char>(*p))) {
160 p++;
161 }
162 return (p - start) >= 3;
163}
164
165int32_t __attribute__((noinline)) parse_offset(const char *&p) {
166 int sign = 1;
167 if (*p == '-') {
168 sign = -1;
169 p++;
170 } else if (*p == '+') {
171 p++;
172 }
173
174 int hours = parse_uint(p);
175 int minutes = 0;
176 int seconds = 0;
177
178 if (*p == ':') {
179 p++;
180 minutes = parse_uint(p);
181 if (*p == ':') {
182 p++;
183 seconds = parse_uint(p);
184 }
185 }
186
187 return sign * (hours * 3600 + minutes * 60 + seconds);
188}
189
190// Helper to parse the optional /time suffix (reuses parse_offset logic)
191static void parse_transition_time(const char *&p, DSTRule &rule) {
192 rule.time_seconds = 2 * 3600; // Default 02:00
193 if (*p == '/') {
194 p++;
196 }
197}
198
199void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) {
200 // J format: day 1-365, Feb 29 is NOT counted even in leap years
201 // So day 60 is always March 1
202 // Iterate forward through months (no array needed)
203 int remaining = julian_day;
204 out_month = 1;
205 while (out_month <= 12) {
206 // Days in month for non-leap year (J format ignores leap years)
207 int dim = days_in_month(2001, out_month); // 2001 is non-leap year
208 if (remaining <= dim) {
209 out_day = remaining;
210 return;
211 }
212 remaining -= dim;
213 out_month++;
214 }
215 out_day = remaining;
216}
217
218void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) {
219 // Plain format: day 0-365, Feb 29 IS counted in leap years
220 // Day 0 = Jan 1
221 int remaining = day_of_year;
222 out_month = 1;
223
224 while (out_month <= 12) {
225 int days_this_month = days_in_month(year, out_month);
226 if (remaining < days_this_month) {
227 out_day = remaining + 1;
228 return;
229 }
230 remaining -= days_this_month;
231 out_month++;
232 }
233
234 // Shouldn't reach here with valid input
235 out_month = 12;
236 out_day = 31;
237}
238
239bool parse_dst_rule(const char *&p, DSTRule &rule) {
240 rule = {}; // Zero initialize
241
242 if (*p == 'M' || *p == 'm') {
243 // M format: Mm.w.d (month.week.day)
245 p++;
246
247 rule.month = parse_uint(p);
248 if (rule.month < 1 || rule.month > 12)
249 return false;
250
251 if (*p++ != '.')
252 return false;
253
254 rule.week = parse_uint(p);
255 if (rule.week < 1 || rule.week > 5)
256 return false;
257
258 if (*p++ != '.')
259 return false;
260
261 rule.day_of_week = parse_uint(p);
262 if (rule.day_of_week > 6)
263 return false;
264
265 } else if (*p == 'J' || *p == 'j') {
266 // J format: Jn (Julian day 1-365, not counting Feb 29)
268 p++;
269
270 rule.day = parse_uint(p);
271 if (rule.day < 1 || rule.day > 365)
272 return false;
273
274 } else if (std::isdigit(static_cast<unsigned char>(*p))) {
275 // Plain number format: n (day 0-365, counting Feb 29)
277
278 rule.day = parse_uint(p);
279 if (rule.day > 365)
280 return false;
281
282 } else {
283 return false;
284 }
285
286 // Parse optional /time suffix
287 parse_transition_time(p, rule);
288
289 return true;
290}
291
292// Calculate days from Jan 1 of given year to given month/day
293static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) {
294 int days = day - 1;
295 for (int m = 1; m < month; m++) {
297 }
298 return days;
299}
300
302 int month, day;
303
304 switch (rule.type) {
306 // Find the nth occurrence of day_of_week in the given month
307 int first_dow = day_of_week(year, rule.month, 1);
308
309 // Days until first occurrence of target day
310 int days_until_first = (rule.day_of_week - first_dow + 7) % 7;
311 int first_occurrence = 1 + days_until_first;
312
313 if (rule.week == 5) {
314 // "Last" occurrence - find the last one in the month
316 day = first_occurrence;
317 while (day + 7 <= dim) {
318 day += 7;
319 }
320 } else {
321 // nth occurrence
322 day = first_occurrence + (rule.week - 1) * 7;
323 }
324 month = rule.month;
325 break;
326 }
327
329 // J format: day 1-365, Feb 29 not counted
331 break;
332
334 // Plain format: day 0-365, Feb 29 counted
336 break;
337
339 // Should never be called with NONE, but handle it gracefully
340 month = 1;
341 day = 1;
342 break;
343 }
344
345 // Calculate days from epoch to this date
346 int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day);
347
348 // Convert to epoch and add transition time and base offset
350}
351
352} // namespace internal
353
354bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) {
355 if (!tz.has_dst()) {
356 return false;
357 }
358
359 int year = internal::epoch_to_year(utc_epoch);
360
361 // Calculate DST start and end for this year
362 // DST start transition happens in standard time
364 // DST end transition happens in daylight time
366
368 // Northern hemisphere: DST is between start and end
369 return (utc_epoch >= dst_start && utc_epoch < dst_end);
370 } else {
371 // Southern hemisphere: DST is outside the range (wraps around year)
372 return (utc_epoch >= dst_start || utc_epoch < dst_end);
373 }
374}
375
376// Remove before 2026.9.0: This parser is bridge code for backward compatibility with
377// older Home Assistant clients that send the timezone as a POSIX TZ string instead of
378// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct
379// directly, this function and the parsing helpers above (skip_tz_name, parse_offset,
380// parse_dst_rule, parse_transition_time) can be removed.
381// See https://github.com/esphome/backlog/issues/91
382bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
383 if (!tz_string || !*tz_string) {
384 return false;
385 }
386
387 const char *p = tz_string;
388
389 // Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false)
390 result.std_offset_seconds = 0;
391 result.dst_offset_seconds = 0;
392 result.dst_start = {};
393 result.dst_end = {};
394
395 // Skip standard timezone name
396 if (!internal::skip_tz_name(p)) {
397 return false;
398 }
399
400 // Parse standard offset (required)
401 if (!*p || (!std::isdigit(static_cast<unsigned char>(*p)) && *p != '+' && *p != '-')) {
402 return false;
403 }
405
406 // Check for DST name
407 if (!*p) {
408 return true; // No DST
409 }
410
411 // If next char is comma, there's no DST name but there are rules (invalid)
412 if (*p == ',') {
413 return false;
414 }
415
416 // Check if there's something that looks like a DST name start
417 // (letter or angle bracket). If not, treat as trailing garbage and return success.
418 if (!std::isalpha(static_cast<unsigned char>(*p)) && *p != '<') {
419 return true; // No DST, trailing characters ignored
420 }
421
422 if (!internal::skip_tz_name(p)) {
423 return false; // Invalid DST name (started but malformed)
424 }
425
426 // Optional DST offset (default is std - 1 hour)
427 if (*p && *p != ',' && (std::isdigit(static_cast<unsigned char>(*p)) || *p == '+' || *p == '-')) {
429 } else {
430 result.dst_offset_seconds = result.std_offset_seconds - 3600;
431 }
432
433 // Parse DST rules (required when DST name is present)
434 if (*p != ',') {
435 // DST name without rules - treat as no DST since we can't determine transitions
436 return true;
437 }
438
439 p++;
440 if (!internal::parse_dst_rule(p, result.dst_start)) {
441 return false;
442 }
443
444 // Second rule is required per POSIX
445 if (*p != ',') {
446 return false;
447 }
448 p++;
449 // has_dst() now returns true since dst_start.type was set by parse_dst_rule
450 return internal::parse_dst_rule(p, result.dst_end);
451}
452
453// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display.
454// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822).
455void format_designation(int32_t posix_offset, char *buf, size_t buf_size) {
456 int32_t display = -posix_offset;
457 char sign = display >= 0 ? '+' : '-';
458 if (display < 0)
459 display = -display;
460 int h = display / 3600;
461 int m = (display % 3600) / 60;
462 snprintf(buf, buf_size, "%c%02d%02d", sign, h, m);
463}
464
465bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) {
466 if (!out_tm) {
467 return false;
468 }
469
470 // Determine DST status once (avoids duplicate is_in_dst calculation)
471 bool in_dst = is_in_dst(utc_epoch, tz);
472 int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds;
473
474 // Apply offset (POSIX offset is positive west, so subtract to get local)
475 time_t local_epoch = utc_epoch - offset;
476
477 internal::epoch_to_tm_utc(local_epoch, out_tm);
478 out_tm->tm_isdst = in_dst ? 1 : 0;
479
480 return true;
481}
482
483} // namespace esphome::time
484
485#ifndef USE_HOST
486// Override libc's localtime functions to use our timezone on embedded platforms.
487// This allows user lambdas calling ::localtime() to get correct local time
488// without needing the TZ environment variable (which pulls in scanf bloat).
489// On host, we use the normal TZ mechanism since there's no memory constraint.
490
491// Thread-safe version
492extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) {
493 if (timer == nullptr || result == nullptr) {
494 return nullptr;
495 }
497 return result;
498}
499
500// Non-thread-safe version (uses static buffer, standard libc behavior)
501extern "C" struct tm *localtime(const time_t *timer) {
502 // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
503 static struct tm localtime_buf;
504 return localtime_r(timer, &localtime_buf);
505}
506#endif // !USE_HOST
507
508#endif // USE_TIME_TIMEZONE
uint8_t m
Definition bl0906.h:1
uint8_t h
Definition bl0906.h:2
uint16_t year
Definition date_entity.h:0
void int int & out_day
Definition posix_tz.cpp:199
int day_of_week(int year, int month, int day)
Calculate day of week for any date (0 = Sunday) Uses a simplified algorithm that works for years 1970...
int days_in_month(int year, int month)
Get the number of days in a month.
Definition posix_tz.cpp:78
int32_t parse_offset(const char *&p)
Parse an offset in format [-]hh[:mm[:ss]].
constexpr int LEAP_YEARS_BEFORE_EPOCH
Definition posix_tz.cpp:41
void struct tm * out_tm
Definition posix_tz.cpp:106
time_t const DSTRule & rule
Definition posix_tz.cpp:301
bool parse_dst_rule(const char *&p, DSTRule &rule)
Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time].
Definition posix_tz.cpp:239
constexpr int SECONDS_PER_DAY
Definition posix_tz.cpp:43
bool skip_tz_name(const char *&p)
Skip a timezone name (letters or <...> quoted format)
Definition posix_tz.cpp:143
void julian_to_month_day(int julian_day, int &month, int &day)
Convert Julian day (J format, 1-365 not counting Feb 29) to month/day.
void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day)
Convert day of year (plain format, 0-365 counting Feb 29) to month/day.
constexpr int EPOCH_YEAR
Definition posix_tz.cpp:40
bool is_leap_year(int year)
Check if a year is a leap year.
Definition posix_tz.cpp:32
time_t const DSTRule int32_t base_offset_seconds
Definition posix_tz.cpp:301
constexpr int DAYS_PER_YEAR
Definition posix_tz.cpp:42
time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds)
Calculate the epoch timestamp for a DST transition in a given year.
void epoch_to_tm_utc(time_t epoch, struct tm *out_tm)
Convert epoch to year/month/day/hour/min/sec (UTC)
void set_global_tz(const ParsedTimezone &tz)
Set the global timezone used by epoch_to_local_tm() when called without a timezone.
Definition posix_tz.cpp:15
bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz)
Check if a given UTC epoch falls within DST for the parsed timezone.
const ParsedTimezone & get_global_tz()
Get the global timezone.
Definition posix_tz.cpp:17
ESPTime __attribute__((noinline)) RealTimeClock
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result)
Parse a POSIX TZ string into a ParsedTimezone struct.
Definition posix_tz.cpp:382
@ JULIAN_NO_LEAP
J format: Jn (day 1-365, Feb 29 not counted)
@ NONE
No DST rule (used to indicate no DST)
@ DAY_OF_YEAR
Plain number: n (day 0-365, Feb 29 counted in leap years)
@ MONTH_WEEK_DAY
M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March)
bool const ParsedTimezone & tz
Definition posix_tz.cpp:354
time_t dst_start
Definition posix_tz.cpp:363
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm)
Convert a UTC epoch to local time using the parsed timezone.
Definition posix_tz.cpp:465
void format_designation(int32_t posix_offset, char *buf, size_t buf_size)
Format a POSIX offset as "+HHMM"/"-HHMM" into buf (must be >= 6 bytes).
Definition posix_tz.cpp:455
struct tm * localtime_r(const time_t *timer, struct tm *result)
Definition posix_tz.cpp:492
struct tm * localtime(const time_t *timer)
Definition posix_tz.cpp:501
static void uint32_t
Rule for DST transition (packed for 32-bit: 12 bytes)
Definition posix_tz.h:19
uint16_t day
Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR)
Definition posix_tz.h:21
DSTRuleType type
Type of rule.
Definition posix_tz.h:22
uint8_t week
Week 1-5, 5 = last (for MONTH_WEEK_DAY)
Definition posix_tz.h:24
int32_t time_seconds
Seconds after midnight (default 7200 = 2:00 AM)
Definition posix_tz.h:20
uint8_t day_of_week
Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY)
Definition posix_tz.h:25
uint8_t month
Month 1-12 (for MONTH_WEEK_DAY)
Definition posix_tz.h:23
Parsed POSIX timezone information (packed for 32-bit: 32 bytes)
Definition posix_tz.h:29
bool has_dst() const
Check if this timezone has DST rules.
Definition posix_tz.h:36
DSTRule dst_end
When DST ends.
Definition posix_tz.h:33
DSTRule dst_start
When DST starts.
Definition posix_tz.h:32
int32_t dst_offset_seconds
DST offset from UTC in seconds.
Definition posix_tz.h:31
int32_t std_offset_seconds
Standard time offset from UTC in seconds (positive = west)
Definition posix_tz.h:30