# What does this implement/fix?
This PR replaces libc's timezone handling with …a lightweight custom implementation, recovering ~9.5KB flash on ESP32 and ~2% of usable RAM on ESP8266. It is mostly tests (126 of them) because time handling needs to be rock solid and consistent across all platforms. The majority of those tests cover the C++ POSIX TZ string parser, which is **temporary bridge code** — after #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. The parser and its tests will be removed before ESPHome 2026.9.0. The remaining tests cover the time conversion functions (`epoch_to_local_tm`, `is_in_dst`, `strptime`) which are permanent.
**This is step 1 of a long-term plan to eliminate all timezone parsing from device firmware entirely.** See [esphome/backlog#91](https://github.com/esphome/backlog/issues/91) for the full roadmap. Since all timezone data originates from Python (codegen or aioesphomeapi), future steps will construct the `ParsedTimezone` struct in Python and send it pre-computed — removing the parser from the binary completely. This PR establishes the target struct format and the conversion functions that will remain.
> **Note:** The `posix_tz.cpp` parser code is **temporary bridge code** that exists solely for backward compatibility. Now that [aioesphomeapi#1505](https://github.com/esphome/aioesphomeapi/pull/1505) has merged, once #14233 merges the only remaining use of the C++ parser is to handle the string-based `timezone` field from older Home Assistant clients that haven't been updated to send the pre-parsed `ParsedTimezone` struct. After a 6-month transition period (target removal: **ESPHome 2026.9.0**), the parser will be removed entirely. The conversion functions (`epoch_to_local_tm`, `is_in_dst`, etc.) and the `ParsedTimezone`/`DSTRule` structs will remain permanently.
## Summary
We were using libc's `localtime()`, `mktime()`, and `tzset()` to handle timezones, but these functions pulled in massive baggage that **only libc itself needed**:
- The entire `scanf` family (~9.8KB flash) - just to parse the TZ environment variable
- Environment variable infrastructure (`setenv`/`getenv`) - only used by libc's `tzset()`
- Double storage of timezone data - once in libc's BSS via the TZ env var, again as a `std::string` in our component
**Nothing else in the codebase used any of this.** On embedded targets, no ESPHome code uses environment variables—only the host platform even has them. It was pure overhead on embedded targets.
We already had `ESPTime` functions to abstract all this away—except they delegated to libc internally, which made them inconsistent across platforms (different newlib versions on ESP32/ESP8266/RP2040/LibreTiny, plus glibc/musl on host). Now they're consistent everywhere.
This PR parses POSIX TZ strings directly, storing the result in a compact 32-byte struct. The libc functions are no longer called, so the linker drops all that dead weight.
### Why Embedded Systems Don't Need TZ Environment Variables
Environment variables don't make sense on embedded systems—there's no subprocess to pass them to. Additionally, there are [reports of memory leaks](https://www.reddit.com/r/Esphome/comments/1auj7ia/handling_utc_time_in_esphome_lambda/) in `setenv`/`tzset` on both Arduino and ESP-IDF frameworks that can crash devices:
- **No shell** - devices boot straight into firmware
- **No other processes** - ESPHome is the only thing running
- **No persistent environment** - `setenv()` only exists in RAM until reboot
- **No external consumers** - nothing else calls `getenv("TZ")`
The old code path was: ESPHome sets TZ env var → libc reads TZ → libc parses TZ with scanf → libc returns local time
But ESPHome already knew the timezone—it just set it! The entire env var dance was just a way to pass data to libc so libc could parse it with `scanf`. Now we skip the middleman.
### Memory Savings
| Platform | Flash Savings | RAM Savings |
|------------|---------------|-------------|
| ESP32-IDF | ~9.5KB | 136 bytes |
| ESP8266 | ~4.1KB | ~850 bytes (272 static + 578 heap) |
| LibreTiny | ~4.7KB | 148 bytes |
| RP2040 | ~3.7KB | 148 bytes |
For context on ESP32, linked FreeRTOS code is ~16KB. The timezone infrastructure we're removing (~9.5KB) is nearly **2/3 the size of the entire used RTOS**—just to parse a string that ESPHome already knew.
On ESP8266 with ~45KB usable RAM after SDK, **850 bytes is nearly 2%** - recovered by not linking unused functions, not storing env vars nothing will ever read, and not double-storing the timezone.
### Configs Without Timezone Also Benefit
Even configs that don't use a timezone save **~656 bytes flash** on ESP8266. Previously, the time component called libc's `localtime()` even without a timezone configured, which just returned UTC anyway but still linked all the libc bloat. Now those calls are gone.
### Bonus: Consistent Cross-Platform Behavior
Different libc implementations (newlib, glibc, musl) handle timezone edge cases slightly differently. By parsing TZ strings ourselves with 126 unit tests, timezone behavior is now **identical** across ESP32, ESP8266, RP2040, LibreTiny, and Host platforms.
### Logging Format Change
The `dump_config()` output now shows human-readable timezone information:
**Before:**
```
[C][time:027]: Timezone: 'CST6CDT,M3.2.0,M11.1.0'
```
**After:**
```
[C][time:030]: Timezone: UTC-6:00 (DST UTC-5:00)
[C][time:030]: Timezone: UTC+5:30
```
### Long-term Roadmap
This PR is **step 1** of [esphome/backlog#91](https://github.com/esphome/backlog/issues/91):
1. **Step 1 (this PR):** Replace libc's bloated timezone parser with a lightweight custom parser → establishes the `ParsedTimezone` struct and conversion functions
2. **Step 2 ✅ (this PR):** Parse timezone in Python codegen → emit `ParsedTimezone` struct directly in generated C++, no runtime parsing needed for configured timezones
3. **Step 3 ✅ (#14233 + [aioesphomeapi#1505](https://github.com/esphome/aioesphomeapi/pull/1505)):** aioesphomeapi sends pre-parsed `ParsedTimezone` fields over protobuf → no runtime parsing for HA-provided timezones either
4. **Step 4 (#14237, ESPHome 2026.9.0):** Remove `parse_posix_tz()` and all parsing helpers from firmware → device only contains conversion functions (`epoch_to_local_tm`, `is_in_dst`). The C++ parser (`posix_tz.cpp`) is only needed during the transition period while older Home Assistant clients still send the timezone as a string instead of the pre-parsed struct. Draft PR #14237 is open to verify flash/RAM savings.
## Types of changes
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [x] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
- [x] Code quality improvements to existing code or addition of tests
- [ ] Other
**Breaking change (extremely limited scope):** We override libc's `localtime()` and `localtime_r()` on embedded platforms, so most user lambdas work unchanged. However, lambdas using `::mktime()`, bare `::strftime()`, or calling `setenv("TZ", ...)`/`tzset()` directly would be affected.
These edge cases are unlikely—all docs examples use ESPHome's time API, and anyone calling `setenv`/`tzset` directly is likely hitting the memory leak in not-well-tested code paths on ESP platforms anyway.
**Docs update:** The [modbus_controller docs](https://esphome.io/components/modbus_controller.html) have an EPEVER example that uses `::localtime()` in a lambda. Updated in esphome/esphome-docs#6011 to use ESPHome's `ESPTime` API (recommended best practice, though the old code will still work).
**Verified:** All external libraries in platformio.ini were checked - none use `localtime()`, `mktime()`, `tzset()`, or `strftime()`:
- qr-code-generator-library, arduino-MLX90393, HaierProtocol, dsmr_parser, Crypto-no-arduino
- lvgl, noise-c, Improv, pngle, AsyncMqttClient-esphome
- FastLED, TM1651, MideaUART, HeatpumpIR, ArduinoJson
- ESPAsyncWebServer, NeoPixelBus, esp_wireguard, ESP32-audioI2S, ESPMicroSpeechFeatures
**Related issue or feature (if applicable):**
- Significant flash/RAM savings for all devices using the time component
- Eliminates platform-specific timezone behavior differences
- Part of [esphome/backlog#91](https://github.com/esphome/backlog/issues/91)
**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
- esphome/esphome-docs#6011 (updates EPEVER lambda example to use ESPTime API)
## Test Environment
- [x] ESP32
- [x] ESP32 IDF
- [x] ESP8266
- [x] RP2040
- [x] BK72xx
- [x] RTL87xx
- [x] LN882x
- [ ] nRF52840
## Example entry for `config.yaml`:
```yaml
# No configuration changes required - existing timezone configs work unchanged
time:
- platform: homeassistant
id: ha_time
timezone: "EST5EDT,M3.2.0,M11.1.0"
```
### Test button to verify `localtime()` override works:
```yaml
time:
- platform: homeassistant
timezone: "CST6CDT,M3.2.0,M11.1.0"
button:
- platform: template
name: "Test localtime Override"
on_press:
- lambda: |-
// Test that ::localtime() returns local time (not UTC) via our override
time_t now = ::time(nullptr);
struct tm *time_info = ::localtime(&now);
ESP_LOGI("test", "::localtime(): %04d-%02d-%02d %02d:%02d:%02d",
time_info->tm_year + 1900, time_info->tm_mon + 1, time_info->tm_mday,
time_info->tm_hour, time_info->tm_min, time_info->tm_sec);
// Compare with ESPTime for verification
auto t = ESPTime::from_epoch_local(now);
ESP_LOGI("test", "ESPTime: %04d-%02d-%02d %02d:%02d:%02d",
t.year, t.month, t.day_of_month, t.hour, t.minute, t.second);
```
## Checklist:
- [x] The code change is tested and works locally.
- [x] Tests have been added to verify that the new code works (under `tests/` folder).
If user exposed functionality or configuration variables are added/changed:
- [x] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).