From 746e5c5ef316f289db722f20a0467fe6a75860be Mon Sep 17 00:00:00 2001 From: proddy Date: Tue, 20 Oct 2026 21:14:45 +0200 Subject: [PATCH] migrate stored TZ formats from IANA labels on boot --- interface/src/app/settings/NTPSettings.tsx | 29 +- interface/src/app/settings/TZ.tsx | 33 +- scripts/generate_tz_zones.py | 83 ++++ src/ESP32React/NTPSettingsService.cpp | 73 ++- src/ESP32React/NTPSettingsService.h | 10 + src/ESP32React/tz_zones.h | 489 +++++++++++++++++++++ 6 files changed, 704 insertions(+), 13 deletions(-) create mode 100644 scripts/generate_tz_zones.py create mode 100644 src/ESP32React/tz_zones.h diff --git a/interface/src/app/settings/NTPSettings.tsx b/interface/src/app/settings/NTPSettings.tsx index f6553db91..591657489 100644 --- a/interface/src/app/settings/NTPSettings.tsx +++ b/interface/src/app/settings/NTPSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -42,6 +42,7 @@ import type { ValidateFieldsError } from 'validators/schema'; import { TIME_ZONES, + migrateNtpTimeZones, selectedTimeZone, timeZoneSelectItemsT, useTimeZoneSelectItems @@ -56,6 +57,7 @@ const NTPSettings = () => { origData, dirtyFlags, setDirtyFlags, + setOrigData, blocker, saveData, errorMessage @@ -81,6 +83,31 @@ const NTPSettings = () => { const [settingTime, setSettingTime] = useState(false); const [processing, setProcessing] = useState(false); const [fieldErrors, setFieldErrors] = useState(); + const migratingTz = useRef(false); + + // If firmware has not yet rewritten tz_format from the label (or the user + // landed here before reboot), persist the current TIME_ZONES values quietly. + useEffect(() => { + if (!data || migratingTz.current || dirtyFlags.length > 0) { + return; + } + const migrated = migrateNtpTimeZones(data); + if (!migrated) { + return; + } + migratingTz.current = true; + void (async () => { + try { + updateDataValue(migrated); + await NTPApi.updateNTPSettings(migrated); + setOrigData(migrated); + } catch { + // Boot-time firmware migration still applies on the next restart. + } finally { + migratingTz.current = false; + } + })(); + }, [data, dirtyFlags.length, setOrigData, updateDataValue]); const { send: updateTime } = useRequest( (local_time: Time) => NTPApi.updateTime(local_time), diff --git a/interface/src/app/settings/TZ.tsx b/interface/src/app/settings/TZ.tsx index 6fb0f98ee..c1326a711 100644 --- a/interface/src/app/settings/TZ.tsx +++ b/interface/src/app/settings/TZ.tsx @@ -466,8 +466,39 @@ export const TIME_ZONES: Record = { // Pre-compute sorted timezone labels for better performance export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort(); +/** Resolve the select value for a saved zone. Prefer the label when it is in + * TIME_ZONES so a stale stored tz_format (after a table update) still shows the + * correct city; firmware migrates the format from the label on boot. */ export function selectedTimeZone(label: string, format: string) { - return TIME_ZONES[label] === format ? label : undefined; + if (label in TIME_ZONES) { + return label; + } + return Object.keys(TIME_ZONES).find((key) => TIME_ZONES[key] === format); +} + +/** Return settings with tz_format / tz_format_t refreshed from TIME_ZONES, or + * null if already current (or labels are unknown). */ +export function migrateNtpTimeZones< + T extends { + tz_label: string; + tz_format: string; + tz_label_t: string; + tz_format_t: string; + } +>(settings: T): T | null { + const tz_format = TIME_ZONES[settings.tz_label]; + const tz_format_t = TIME_ZONES[settings.tz_label_t]; + const next = { ...settings }; + let changed = false; + if (tz_format && tz_format !== settings.tz_format) { + next.tz_format = tz_format; + changed = true; + } + if (tz_format_t && tz_format_t !== settings.tz_format_t) { + next.tz_format_t = tz_format_t; + changed = true; + } + return changed ? next : null; } const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => ( diff --git a/scripts/generate_tz_zones.py b/scripts/generate_tz_zones.py new file mode 100644 index 000000000..d795fc764 --- /dev/null +++ b/scripts/generate_tz_zones.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Generate src/ESP32React/tz_zones.h from interface/src/app/settings/TZ.tsx. + +Run from the repo root: + python3 scripts/generate_tz_zones.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TZ_TSX = ROOT / "interface" / "src" / "app" / "settings" / "TZ.tsx" +OUT_H = ROOT / "src" / "ESP32React" / "tz_zones.h" + + +def parse_time_zones(text: str) -> list[tuple[str, str]]: + match = re.search( + r"export const TIME_ZONES: Record = \{([\s\S]*?)\n\};", + text, + ) + if not match: + raise SystemExit("TIME_ZONES table not found in TZ.tsx") + entries = re.findall(r"'((?:\\'|[^'])*)': '((?:\\'|[^'])*)'", match.group(1)) + if not entries: + raise SystemExit("no TIME_ZONES entries parsed") + return sorted(entries, key=lambda item: item[0]) + + +def c_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def main() -> int: + entries = parse_time_zones(TZ_TSX.read_text(encoding="utf-8")) + max_format = max(len(fmt) for _, fmt in entries) + + lines = [ + "/* Auto-generated by scripts/generate_tz_zones.py — do not edit. */", + "#pragma once", + "", + "#include ", + "#include ", + "", + "struct EmsespTzZone {", + " const char * label;", + " const char * format;", + "};", + "", + f"// Longest POSIX TZ value in the table is {max_format} chars.", + "static constexpr size_t EMSESP_TZ_ZONE_COUNT = " + f"{len(entries)};", + "static const EmsespTzZone EMSESP_TZ_ZONES[EMSESP_TZ_ZONE_COUNT] = {", + ] + for label, fmt in entries: + lines.append(f" {{{c_string(label)}, {c_string(fmt)}}},") + lines += [ + "};", + "", + "inline int emsesp_tz_zone_cmp(const void * key, const void * element) {", + " return strcmp(static_cast(key), static_cast(element)->label);", + "}", + "", + "// Returns the POSIX TZ string for an IANA label, or nullptr if unknown.", + "inline const char * emsesp_tz_format_for_label(const char * label) {", + " if (label == nullptr || *label == '\\0') {", + " return nullptr;", + " }", + " const auto * found = static_cast(", + " bsearch(label, EMSESP_TZ_ZONES, EMSESP_TZ_ZONE_COUNT, sizeof(EmsespTzZone), emsesp_tz_zone_cmp));", + " return found != nullptr ? found->format : nullptr;", + "}", + "", + ] + OUT_H.write_text("\n".join(lines), encoding="utf-8") + print(f"Wrote {OUT_H.relative_to(ROOT)} ({len(entries)} zones, max format {max_format})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ESP32React/NTPSettingsService.cpp b/src/ESP32React/NTPSettingsService.cpp index 2e992aa4a..d7fccd294 100644 --- a/src/ESP32React/NTPSettingsService.cpp +++ b/src/ESP32React/NTPSettingsService.cpp @@ -1,7 +1,25 @@ #include "NTPSettingsService.h" +#include "tz_zones.h" + #include +namespace { + +String trimmedTzFormat(const String & format) { + int space = format.indexOf(' '); + return space < 0 ? format : format.substring(0, static_cast(space)); +} + +void padTzFormat(String & format) { + format.reserve(NTP_TZ_FORMAT_LEN); + while (format.length() < NTP_TZ_FORMAT_LEN) { + format += ' '; + } +} + +} // namespace + NTPSettingsService::NTPSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) : _httpEndpoint(NTPSettings::read, NTPSettings::update, this, server, NTP_SETTINGS_SERVICE_PATH, securityManager) , _fsPersistence(NTPSettings::read, NTPSettings::update, this, fs, NTP_SETTINGS_FILE) @@ -16,6 +34,13 @@ NTPSettingsService::NTPSettingsService(AsyncWebServer * server, FS * fs, Securit void NTPSettingsService::begin() { _fsPersistence.readFromFS(); + + // After an OTA that updates tz_zones.h, rewrite stored POSIX strings from the + // saved IANA labels so localtime / thermostat sync use the current rules + // without the user re-selecting the zone in the WebUI. + update([](NTPSettings & settings) { + return NTPSettings::syncFormatsFromLabels(settings) ? StateUpdateResult::CHANGED : StateUpdateResult::UNCHANGED; + }); } void NTPSettingsService::loop() { @@ -77,15 +102,40 @@ void NTPSettings::read(NTPSettings & settings, JsonObject root) { root["enabled"] = settings.enabled; root["server"] = settings.server; root["tz_label"] = settings.tzLabel; - root["tz_format"] = settings.tzFormat.substring(0, settings.tzFormat.indexOf(" ")); + root["tz_format"] = trimmedTzFormat(settings.tzFormat); root["thermostat_sync"] = settings.thermostat_sync; root["tz_label_t"] = settings.tzLabelT; - root["tz_format_t"] = settings.tzFormatT.substring(0, settings.tzFormatT.indexOf(" ")); + root["tz_format_t"] = trimmedTzFormat(settings.tzFormatT); +} + +bool NTPSettings::syncFormatsFromLabels(NTPSettings & settings) { + bool changed = false; + + auto apply = [&](const String & label, String & format) { + const char * mapped = emsesp_tz_format_for_label(label.c_str()); + if (mapped == nullptr) { + return; + } + if (trimmedTzFormat(format) == mapped) { + // Still re-pad in case NTP_TZ_FORMAT_LEN grew since the value was stored. + if (format.length() != NTP_TZ_FORMAT_LEN) { + format = mapped; + padTzFormat(format); + changed = true; + } + return; + } + format = mapped; + padTzFormat(format); + changed = true; + }; + + apply(settings.tzLabel, settings.tzFormat); + apply(settings.tzLabelT, settings.tzFormatT); + return changed; } StateUpdateResult NTPSettings::update(JsonObject root, NTPSettings & settings) { - settings.tzFormat.reserve(40); - settings.tzFormatT.reserve(40); settings.enabled = root["enabled"] | FACTORY_NTP_ENABLED; settings.server = root["server"] | FACTORY_NTP_SERVER; settings.tzLabel = root["tz_label"] | FACTORY_NTP_TIME_ZONE_LABEL; @@ -93,14 +143,15 @@ StateUpdateResult NTPSettings::update(JsonObject root, NTPSettings & settings) { settings.thermostat_sync = root["thermostat_sync"] | FACTORY_NTP_THERMOSTAT_SYNC; settings.tzLabelT = root["tz_label_t"] | FACTORY_NTP_TIME_ZONE_LABEL; settings.tzFormatT = root["tz_format_t"] | FACTORY_NTP_TIME_ZONE_FORMAT; - // The format string must have same length to avoid memory leak, #3184 - while (settings.tzFormat.length() < 40) { - settings.tzFormat += " "; - } - while (settings.tzFormatT.length() < 40) { - settings.tzFormatT += " "; - } + // Do not call syncFormatsFromLabels here: readFromFS() uses this updater + // via updateWithoutPropagation, and a sync at that point would not be + // written back. Boot migration runs in NTPSettingsService::begin() instead. + // The WebUI always POSTs tz_format from TIME_ZONES when the user saves. + + // The format string must have a fixed length to avoid a setenv leak, #3184. + padTzFormat(settings.tzFormat); + padTzFormat(settings.tzFormatT); return StateUpdateResult::CHANGED; } diff --git a/src/ESP32React/NTPSettingsService.h b/src/ESP32React/NTPSettingsService.h index 59443a43b..83f5c0747 100644 --- a/src/ESP32React/NTPSettingsService.h +++ b/src/ESP32React/NTPSettingsService.h @@ -32,6 +32,12 @@ #define NTP_SETTINGS_SERVICE_PATH "/rest/ntpSettings" #define TIME_PATH "/rest/time" +// Fixed length for tz_format passed to setenv to avoid the realloc leak in #3184. +// Must be >= longest POSIX TZ string in tz_zones.h (currently 44 for Pacific/Chatham). +#ifndef NTP_TZ_FORMAT_LEN +#define NTP_TZ_FORMAT_LEN 48 +#endif + class NTPSettings { public: bool enabled = FACTORY_NTP_ENABLED; @@ -44,6 +50,10 @@ class NTPSettings { static void read(NTPSettings & settings, JsonObject root); static StateUpdateResult update(JsonObject root, NTPSettings & settings); + + // Refresh tz_format / tz_format_t from the IANA label using tz_zones.h. + // Returns true if either format string changed. + static bool syncFormatsFromLabels(NTPSettings & settings); }; class NTPSettingsService : public StatefulService { diff --git a/src/ESP32React/tz_zones.h b/src/ESP32React/tz_zones.h new file mode 100644 index 000000000..fddbdab0e --- /dev/null +++ b/src/ESP32React/tz_zones.h @@ -0,0 +1,489 @@ +/* Auto-generated by scripts/generate_tz_zones.py — do not edit. */ +#pragma once + +#include +#include + +struct EmsespTzZone { + const char * label; + const char * format; +}; + +// Longest POSIX TZ value in the table is 44 chars. +static constexpr size_t EMSESP_TZ_ZONE_COUNT = 460; +static const EmsespTzZone EMSESP_TZ_ZONES[EMSESP_TZ_ZONE_COUNT] = { + {"Africa/Abidjan", "GMT0"}, + {"Africa/Accra", "GMT0"}, + {"Africa/Addis_Ababa", "EAT-3"}, + {"Africa/Algiers", "CET-1"}, + {"Africa/Asmara", "EAT-3"}, + {"Africa/Bamako", "GMT0"}, + {"Africa/Bangui", "WAT-1"}, + {"Africa/Banjul", "GMT0"}, + {"Africa/Bissau", "GMT0"}, + {"Africa/Blantyre", "CAT-2"}, + {"Africa/Brazzaville", "WAT-1"}, + {"Africa/Bujumbura", "CAT-2"}, + {"Africa/Cairo", "EET-2EEST,M4.5.5/0,M10.5.4/24"}, + {"Africa/Casablanca", "<+00>0"}, + {"Africa/Ceuta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Africa/Conakry", "GMT0"}, + {"Africa/Dakar", "GMT0"}, + {"Africa/Dar_es_Salaam", "EAT-3"}, + {"Africa/Djibouti", "EAT-3"}, + {"Africa/Douala", "WAT-1"}, + {"Africa/El_Aaiun", "<+00>0"}, + {"Africa/Freetown", "GMT0"}, + {"Africa/Gaborone", "CAT-2"}, + {"Africa/Harare", "CAT-2"}, + {"Africa/Johannesburg", "SAST-2"}, + {"Africa/Juba", "CAT-2"}, + {"Africa/Kampala", "EAT-3"}, + {"Africa/Khartoum", "CAT-2"}, + {"Africa/Kigali", "CAT-2"}, + {"Africa/Kinshasa", "WAT-1"}, + {"Africa/Lagos", "WAT-1"}, + {"Africa/Libreville", "WAT-1"}, + {"Africa/Lome", "GMT0"}, + {"Africa/Luanda", "WAT-1"}, + {"Africa/Lubumbashi", "CAT-2"}, + {"Africa/Lusaka", "CAT-2"}, + {"Africa/Malabo", "WAT-1"}, + {"Africa/Maputo", "CAT-2"}, + {"Africa/Maseru", "SAST-2"}, + {"Africa/Mbabane", "SAST-2"}, + {"Africa/Mogadishu", "EAT-3"}, + {"Africa/Monrovia", "GMT0"}, + {"Africa/Nairobi", "EAT-3"}, + {"Africa/Ndjamena", "WAT-1"}, + {"Africa/Niamey", "WAT-1"}, + {"Africa/Nouakchott", "GMT0"}, + {"Africa/Ouagadougou", "GMT0"}, + {"Africa/Porto-Novo", "WAT-1"}, + {"Africa/Sao_Tome", "GMT0"}, + {"Africa/Tripoli", "EET-2"}, + {"Africa/Tunis", "CET-1"}, + {"Africa/Windhoek", "CAT-2"}, + {"America/Adak", "HST10HDT,M3.2.0,M11.1.0"}, + {"America/Anchorage", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Anguilla", "AST4"}, + {"America/Antigua", "AST4"}, + {"America/Araguaina", "<-03>3"}, + {"America/Argentina/Buenos_Aires", "<-03>3"}, + {"America/Argentina/Catamarca", "<-03>3"}, + {"America/Argentina/Cordoba", "<-03>3"}, + {"America/Argentina/Jujuy", "<-03>3"}, + {"America/Argentina/La_Rioja", "<-03>3"}, + {"America/Argentina/Mendoza", "<-03>3"}, + {"America/Argentina/Rio_Gallegos", "<-03>3"}, + {"America/Argentina/Salta", "<-03>3"}, + {"America/Argentina/San_Juan", "<-03>3"}, + {"America/Argentina/San_Luis", "<-03>3"}, + {"America/Argentina/Tucuman", "<-03>3"}, + {"America/Argentina/Ushuaia", "<-03>3"}, + {"America/Aruba", "AST4"}, + {"America/Asuncion", "<-03>3"}, + {"America/Atikokan", "EST5"}, + {"America/Bahia", "<-03>3"}, + {"America/Bahia_Banderas", "CST6"}, + {"America/Barbados", "AST4"}, + {"America/Belem", "<-03>3"}, + {"America/Belize", "CST6"}, + {"America/Blanc-Sablon", "AST4"}, + {"America/Boa_Vista", "<-04>4"}, + {"America/Bogota", "<-05>5"}, + {"America/Boise", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Cambridge_Bay", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Campo_Grande", "<-04>4"}, + {"America/Cancun", "EST5"}, + {"America/Caracas", "<-04>4"}, + {"America/Cayenne", "<-03>3"}, + {"America/Cayman", "EST5"}, + {"America/Chicago", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Chihuahua", "CST6"}, + {"America/Costa_Rica", "CST6"}, + {"America/Creston", "MST7"}, + {"America/Cuiaba", "<-04>4"}, + {"America/Curacao", "AST4"}, + {"America/Danmarkshavn", "GMT0"}, + {"America/Dawson", "MST7"}, + {"America/Dawson_Creek", "MST7"}, + {"America/Denver", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Detroit", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Dominica", "AST4"}, + {"America/Edmonton", "CST6"}, + {"America/Eirunepe", "<-05>5"}, + {"America/El_Salvador", "CST6"}, + {"America/Fort_Nelson", "MST7"}, + {"America/Fortaleza", "<-03>3"}, + {"America/Glace_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Godthab", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + {"America/Goose_Bay", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Grand_Turk", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Grenada", "AST4"}, + {"America/Guadeloupe", "AST4"}, + {"America/Guatemala", "CST6"}, + {"America/Guayaquil", "<-05>5"}, + {"America/Guyana", "<-04>4"}, + {"America/Halifax", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Havana", "CST5CDT,M3.2.0/0,M11.1.0/1"}, + {"America/Hermosillo", "MST7"}, + {"America/Indiana/Indianapolis", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Knox", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Marengo", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Petersburg", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Tell_City", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Vevay", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Vincennes", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Indiana/Winamac", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Inuvik", "CST6"}, + {"America/Iqaluit", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Jamaica", "EST5"}, + {"America/Juneau", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Kentucky/Louisville", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Kentucky/Monticello", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Kralendijk", "AST4"}, + {"America/La_Paz", "<-04>4"}, + {"America/Lima", "<-05>5"}, + {"America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Lower_Princes", "AST4"}, + {"America/Maceio", "<-03>3"}, + {"America/Managua", "CST6"}, + {"America/Manaus", "<-04>4"}, + {"America/Marigot", "AST4"}, + {"America/Martinique", "AST4"}, + {"America/Matamoros", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Mazatlan", "MST7"}, + {"America/Menominee", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Merida", "CST6"}, + {"America/Metlakatla", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Mexico_City", "CST6"}, + {"America/Miquelon", "<-03>3<-02>,M3.2.0,M11.1.0"}, + {"America/Moncton", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Monterrey", "CST6"}, + {"America/Montevideo", "<-03>3"}, + {"America/Montreal", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Montserrat", "AST4"}, + {"America/Nassau", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/New_York", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Nipigon", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Nome", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Noronha", "<-02>2"}, + {"America/North_Dakota/Beulah", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/North_Dakota/Center", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/North_Dakota/New_Salem", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Ojinaga", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Panama", "EST5"}, + {"America/Pangnirtung", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Paramaribo", "<-03>3"}, + {"America/Phoenix", "MST7"}, + {"America/Port-au-Prince", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Port_of_Spain", "AST4"}, + {"America/Porto_Velho", "<-04>4"}, + {"America/Puerto_Rico", "AST4"}, + {"America/Punta_Arenas", "<-03>3"}, + {"America/Rainy_River", "EST5"}, + {"America/Rankin_Inlet", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Recife", "<-03>3"}, + {"America/Regina", "CST6"}, + {"America/Resolute", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Rio_Branco", "<-05>5"}, + {"America/Santarem", "<-03>3"}, + {"America/Santiago", "<-04>4<-03>,M9.1.6/24,M4.1.6/24"}, + {"America/Santo_Domingo", "AST4"}, + {"America/Sao_Paulo", "<-03>3"}, + {"America/Scoresbysund", "<-02>2<-01>,M3.5.0/-1,M10.5.0/0"}, + {"America/Sitka", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/St_Barthelemy", "AST4"}, + {"America/St_Johns", "NST3:30NDT,M3.2.0,M11.1.0"}, + {"America/St_Kitts", "AST4"}, + {"America/St_Lucia", "AST4"}, + {"America/St_Thomas", "AST4"}, + {"America/St_Vincent", "AST4"}, + {"America/Swift_Current", "CST6"}, + {"America/Tegucigalpa", "CST6"}, + {"America/Thule", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Thunder_Bay", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Tijuana", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Toronto", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Tortola", "AST4"}, + {"America/Vancouver", "MST7"}, + {"America/Whitehorse", "MST7"}, + {"America/Winnipeg", "EST5"}, + {"America/Yakutat", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Yellowknife", "CST6"}, + {"Antarctica/Casey", "<+08>-8"}, + {"Antarctica/Davis", "<+07>-7"}, + {"Antarctica/DumontDUrville", "<+10>-10"}, + {"Antarctica/Macquarie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Antarctica/Mawson", "<+05>-5"}, + {"Antarctica/McMurdo", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"Antarctica/Palmer", "<-03>3"}, + {"Antarctica/Rothera", "<-03>3"}, + {"Antarctica/Syowa", "<+03>-3"}, + {"Antarctica/Troll", "<+00>0<+02>-2,M3.5.0/1,M10.5.0/3"}, + {"Antarctica/Vostok", "<+05>-5"}, + {"Arctic/Longyearbyen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Asia/Aden", "<+03>-3"}, + {"Asia/Almaty", "<+05>-5"}, + {"Asia/Amman", "<+03>-3"}, + {"Asia/Anadyr", "<+12>-12"}, + {"Asia/Aqtau", "<+05>-5"}, + {"Asia/Aqtobe", "<+05>-5"}, + {"Asia/Ashgabat", "<+05>-5"}, + {"Asia/Atyrau", "<+05>-5"}, + {"Asia/Baghdad", "<+03>-3"}, + {"Asia/Bahrain", "<+03>-3"}, + {"Asia/Baku", "<+04>-4"}, + {"Asia/Bangkok", "<+07>-7"}, + {"Asia/Barnaul", "<+07>-7"}, + {"Asia/Beirut", "EET-2EEST,M3.5.0/0,M10.5.0/0"}, + {"Asia/Bishkek", "<+06>-6"}, + {"Asia/Brunei", "<+08>-8"}, + {"Asia/Chita", "<+09>-9"}, + {"Asia/Choibalsan", "<+08>-8"}, + {"Asia/Colombo", "<+0530>-5:30"}, + {"Asia/Damascus", "<+03>-3"}, + {"Asia/Dhaka", "<+06>-6"}, + {"Asia/Dili", "<+09>-9"}, + {"Asia/Dubai", "<+04>-4"}, + {"Asia/Dushanbe", "<+05>-5"}, + {"Asia/Famagusta", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Asia/Gaza", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + {"Asia/Hebron", "EET-2EEST,M3.4.4/50,M10.4.4/50"}, + {"Asia/Ho_Chi_Minh", "<+07>-7"}, + {"Asia/Hong_Kong", "HKT-8"}, + {"Asia/Hovd", "<+07>-7"}, + {"Asia/Irkutsk", "<+08>-8"}, + {"Asia/Jakarta", "WIB-7"}, + {"Asia/Jayapura", "WIT-9"}, + {"Asia/Jerusalem", "IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Asia/Kabul", "<+0430>-4:30"}, + {"Asia/Kamchatka", "<+12>-12"}, + {"Asia/Karachi", "PKT-5"}, + {"Asia/Kathmandu", "<+0545>-5:45"}, + {"Asia/Khandyga", "<+09>-9"}, + {"Asia/Kolkata", "IST-5:30"}, + {"Asia/Krasnoyarsk", "<+07>-7"}, + {"Asia/Kuala_Lumpur", "<+08>-8"}, + {"Asia/Kuching", "<+08>-8"}, + {"Asia/Kuwait", "<+03>-3"}, + {"Asia/Macau", "CST-8"}, + {"Asia/Magadan", "<+11>-11"}, + {"Asia/Makassar", "WITA-8"}, + {"Asia/Manila", "PST-8"}, + {"Asia/Muscat", "<+04>-4"}, + {"Asia/Nicosia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Asia/Novokuznetsk", "<+07>-7"}, + {"Asia/Novosibirsk", "<+07>-7"}, + {"Asia/Omsk", "<+06>-6"}, + {"Asia/Oral", "<+05>-5"}, + {"Asia/Phnom_Penh", "<+07>-7"}, + {"Asia/Pontianak", "WIB-7"}, + {"Asia/Pyongyang", "KST-9"}, + {"Asia/Qatar", "<+03>-3"}, + {"Asia/Qyzylorda", "<+05>-5"}, + {"Asia/Riyadh", "<+03>-3"}, + {"Asia/Sakhalin", "<+11>-11"}, + {"Asia/Samarkand", "<+05>-5"}, + {"Asia/Seoul", "KST-9"}, + {"Asia/Shanghai", "CST-8"}, + {"Asia/Singapore", "<+08>-8"}, + {"Asia/Srednekolymsk", "<+11>-11"}, + {"Asia/Taipei", "CST-8"}, + {"Asia/Tashkent", "<+05>-5"}, + {"Asia/Tbilisi", "<+04>-4"}, + {"Asia/Tehran", "<+0330>-3:30"}, + {"Asia/Thimphu", "<+06>-6"}, + {"Asia/Tokyo", "JST-9"}, + {"Asia/Tomsk", "<+07>-7"}, + {"Asia/Ulaanbaatar", "<+08>-8"}, + {"Asia/Urumqi", "<+06>-6"}, + {"Asia/Ust-Nera", "<+10>-10"}, + {"Asia/Vientiane", "<+07>-7"}, + {"Asia/Vladivostok", "<+10>-10"}, + {"Asia/Yakutsk", "<+09>-9"}, + {"Asia/Yangon", "<+0630>-6:30"}, + {"Asia/Yekaterinburg", "<+05>-5"}, + {"Asia/Yerevan", "<+04>-4"}, + {"Atlantic/Azores", "<-01>1<+00>,M3.5.0/0,M10.5.0/1"}, + {"Atlantic/Bermuda", "AST4ADT,M3.2.0,M11.1.0"}, + {"Atlantic/Canary", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Cape_Verde", "<-01>1"}, + {"Atlantic/Faroe", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Madeira", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Atlantic/Reykjavik", "GMT0"}, + {"Atlantic/South_Georgia", "<-02>2"}, + {"Atlantic/St_Helena", "GMT0"}, + {"Atlantic/Stanley", "<-03>3"}, + {"Australia/Adelaide", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Australia/Brisbane", "AEST-10"}, + {"Australia/Broken_Hill", "ACST-9:30ACDT,M10.1.0,M4.1.0/3"}, + {"Australia/Currie", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Darwin", "ACST-9:30"}, + {"Australia/Eucla", "<+0845>-8:45"}, + {"Australia/Hobart", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Lindeman", "AEST-10"}, + {"Australia/Lord_Howe", "<+1030>-10:30<+11>-11,M10.1.0,M4.1.0"}, + {"Australia/Melbourne", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Perth", "AWST-8"}, + {"Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Etc/GMT", "GMT0"}, + {"Etc/GMT+0", "GMT0"}, + {"Etc/GMT+1", "<-01>1"}, + {"Etc/GMT+10", "<-10>10"}, + {"Etc/GMT+11", "<-11>11"}, + {"Etc/GMT+12", "<-12>12"}, + {"Etc/GMT+2", "<-02>2"}, + {"Etc/GMT+3", "<-03>3"}, + {"Etc/GMT+4", "<-04>4"}, + {"Etc/GMT+5", "<-05>5"}, + {"Etc/GMT+6", "<-06>6"}, + {"Etc/GMT+7", "<-07>7"}, + {"Etc/GMT+8", "<-08>8"}, + {"Etc/GMT+9", "<-09>9"}, + {"Etc/GMT-0", "GMT0"}, + {"Etc/GMT-1", "<+01>-1"}, + {"Etc/GMT-10", "<+10>-10"}, + {"Etc/GMT-11", "<+11>-11"}, + {"Etc/GMT-12", "<+12>-12"}, + {"Etc/GMT-13", "<+13>-13"}, + {"Etc/GMT-14", "<+14>-14"}, + {"Etc/GMT-2", "<+02>-2"}, + {"Etc/GMT-3", "<+03>-3"}, + {"Etc/GMT-4", "<+04>-4"}, + {"Etc/GMT-5", "<+05>-5"}, + {"Etc/GMT-6", "<+06>-6"}, + {"Etc/GMT-7", "<+07>-7"}, + {"Etc/GMT-8", "<+08>-8"}, + {"Etc/GMT-9", "<+09>-9"}, + {"Etc/GMT0", "GMT0"}, + {"Etc/Greenwich", "GMT0"}, + {"Etc/UCT", "UTC0"}, + {"Etc/UTC", "UTC0"}, + {"Etc/Universal", "UTC0"}, + {"Etc/Zulu", "UTC0"}, + {"Europe/Amsterdam", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Andorra", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Astrakhan", "<+04>-4"}, + {"Europe/Athens", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Belgrade", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Bratislava", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Bucharest", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Budapest", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Busingen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Chisinau", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Copenhagen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Dublin", "IST-1GMT0,M10.5.0,M3.5.0/1"}, + {"Europe/Gibraltar", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Guernsey", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Isle_of_Man", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Istanbul", "<+03>-3"}, + {"Europe/Jersey", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Kaliningrad", "EET-2"}, + {"Europe/Kiev", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Kirov", "MSK-3"}, + {"Europe/Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Europe/Ljubljana", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/London", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Luxembourg", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Madrid", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Malta", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Mariehamn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Minsk", "<+03>-3"}, + {"Europe/Monaco", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Moscow", "MSK-3"}, + {"Europe/Oslo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Podgorica", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Prague", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Riga", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Rome", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Samara", "<+04>-4"}, + {"Europe/San_Marino", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Sarajevo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Saratov", "<+04>-4"}, + {"Europe/Simferopol", "MSK-3"}, + {"Europe/Skopje", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Sofia", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Stockholm", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Tallinn", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Tirane", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Ulyanovsk", "<+04>-4"}, + {"Europe/Uzhgorod", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Vaduz", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vatican", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vienna", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vilnius", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Volgograd", "MSK-3"}, + {"Europe/Warsaw", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Zagreb", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Zaporozhye", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Zurich", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Indian/Antananarivo", "EAT-3"}, + {"Indian/Chagos", "<+06>-6"}, + {"Indian/Christmas", "<+07>-7"}, + {"Indian/Cocos", "<+0630>-6:30"}, + {"Indian/Comoro", "EAT-3"}, + {"Indian/Kerguelen", "<+05>-5"}, + {"Indian/Mahe", "<+04>-4"}, + {"Indian/Maldives", "<+05>-5"}, + {"Indian/Mauritius", "<+04>-4"}, + {"Indian/Mayotte", "EAT-3"}, + {"Indian/Reunion", "<+04>-4"}, + {"Pacific/Apia", "<+13>-13"}, + {"Pacific/Auckland", "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"Pacific/Bougainville", "<+11>-11"}, + {"Pacific/Chatham", "<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45"}, + {"Pacific/Chuuk", "<+10>-10"}, + {"Pacific/Easter", "<-06>6<-05>,M9.1.6/22,M4.1.6/22"}, + {"Pacific/Efate", "<+11>-11"}, + {"Pacific/Enderbury", "<+13>-13"}, + {"Pacific/Fakaofo", "<+13>-13"}, + {"Pacific/Fiji", "<+12>-12"}, + {"Pacific/Funafuti", "<+12>-12"}, + {"Pacific/Galapagos", "<-06>6"}, + {"Pacific/Gambier", "<-09>9"}, + {"Pacific/Guadalcanal", "<+11>-11"}, + {"Pacific/Guam", "ChST-10"}, + {"Pacific/Honolulu", "HST10"}, + {"Pacific/Kiritimati", "<+14>-14"}, + {"Pacific/Kosrae", "<+11>-11"}, + {"Pacific/Kwajalein", "<+12>-12"}, + {"Pacific/Majuro", "<+12>-12"}, + {"Pacific/Marquesas", "<-0930>9:30"}, + {"Pacific/Midway", "SST11"}, + {"Pacific/Nauru", "<+12>-12"}, + {"Pacific/Niue", "<-11>11"}, + {"Pacific/Norfolk", "<+11>-11<+12>,M10.1.0,M4.1.0/3"}, + {"Pacific/Noumea", "<+11>-11"}, + {"Pacific/Pago_Pago", "SST11"}, + {"Pacific/Palau", "<+09>-9"}, + {"Pacific/Pitcairn", "<-08>8"}, + {"Pacific/Pohnpei", "<+11>-11"}, + {"Pacific/Port_Moresby", "<+10>-10"}, + {"Pacific/Rarotonga", "<-10>10"}, + {"Pacific/Saipan", "ChST-10"}, + {"Pacific/Tahiti", "<-10>10"}, + {"Pacific/Tarawa", "<+12>-12"}, + {"Pacific/Tongatapu", "<+13>-13"}, + {"Pacific/Wake", "<+12>-12"}, + {"Pacific/Wallis", "<+12>-12"}, +}; + +inline int emsesp_tz_zone_cmp(const void * key, const void * element) { + return strcmp(static_cast(key), static_cast(element)->label); +} + +// Returns the POSIX TZ string for an IANA label, or nullptr if unknown. +inline const char * emsesp_tz_format_for_label(const char * label) { + if (label == nullptr || *label == '\0') { + return nullptr; + } + const auto * found = static_cast( + bsearch(label, EMSESP_TZ_ZONES, EMSESP_TZ_ZONE_COUNT, sizeof(EmsespTzZone), emsesp_tz_zone_cmp)); + return found != nullptr ? found->format : nullptr; +}