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