migrate stored TZ formats from IANA labels on boot

This commit is contained in:
proddy
2026-10-20 21:14:45 +02:00
parent 955a6d8c56
commit 746e5c5ef3
6 changed files with 704 additions and 13 deletions
+28 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -42,6 +42,7 @@ import type { ValidateFieldsError } from 'validators/schema';
import { import {
TIME_ZONES, TIME_ZONES,
migrateNtpTimeZones,
selectedTimeZone, selectedTimeZone,
timeZoneSelectItemsT, timeZoneSelectItemsT,
useTimeZoneSelectItems useTimeZoneSelectItems
@@ -56,6 +57,7 @@ const NTPSettings = () => {
origData, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
setOrigData,
blocker, blocker,
saveData, saveData,
errorMessage errorMessage
@@ -81,6 +83,31 @@ const NTPSettings = () => {
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
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( const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time), (local_time: Time) => NTPApi.updateTime(local_time),
+32 -1
View File
@@ -466,8 +466,39 @@ export const TIME_ZONES: Record<string, string> = {
// Pre-compute sorted timezone labels for better performance // Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort(); 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) { 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) => ( const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
+83
View File
@@ -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<string, string> = \{([\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 <stdlib.h>",
"#include <string.h>",
"",
"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<const char *>(key), static_cast<const EmsespTzZone *>(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<const EmsespTzZone *>(",
" 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())
+62 -11
View File
@@ -1,7 +1,25 @@
#include "NTPSettingsService.h" #include "NTPSettingsService.h"
#include "tz_zones.h"
#include <emsesp.h> #include <emsesp.h>
namespace {
String trimmedTzFormat(const String & format) {
int space = format.indexOf(' ');
return space < 0 ? format : format.substring(0, static_cast<unsigned int>(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) NTPSettingsService::NTPSettingsService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager)
: _httpEndpoint(NTPSettings::read, NTPSettings::update, this, server, NTP_SETTINGS_SERVICE_PATH, securityManager) : _httpEndpoint(NTPSettings::read, NTPSettings::update, this, server, NTP_SETTINGS_SERVICE_PATH, securityManager)
, _fsPersistence(NTPSettings::read, NTPSettings::update, this, fs, NTP_SETTINGS_FILE) , _fsPersistence(NTPSettings::read, NTPSettings::update, this, fs, NTP_SETTINGS_FILE)
@@ -16,6 +34,13 @@ NTPSettingsService::NTPSettingsService(AsyncWebServer * server, FS * fs, Securit
void NTPSettingsService::begin() { void NTPSettingsService::begin() {
_fsPersistence.readFromFS(); _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() { void NTPSettingsService::loop() {
@@ -77,15 +102,40 @@ void NTPSettings::read(NTPSettings & settings, JsonObject root) {
root["enabled"] = settings.enabled; root["enabled"] = settings.enabled;
root["server"] = settings.server; root["server"] = settings.server;
root["tz_label"] = settings.tzLabel; 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["thermostat_sync"] = settings.thermostat_sync;
root["tz_label_t"] = settings.tzLabelT; 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) { StateUpdateResult NTPSettings::update(JsonObject root, NTPSettings & settings) {
settings.tzFormat.reserve(40);
settings.tzFormatT.reserve(40);
settings.enabled = root["enabled"] | FACTORY_NTP_ENABLED; settings.enabled = root["enabled"] | FACTORY_NTP_ENABLED;
settings.server = root["server"] | FACTORY_NTP_SERVER; settings.server = root["server"] | FACTORY_NTP_SERVER;
settings.tzLabel = root["tz_label"] | FACTORY_NTP_TIME_ZONE_LABEL; 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.thermostat_sync = root["thermostat_sync"] | FACTORY_NTP_THERMOSTAT_SYNC;
settings.tzLabelT = root["tz_label_t"] | FACTORY_NTP_TIME_ZONE_LABEL; settings.tzLabelT = root["tz_label_t"] | FACTORY_NTP_TIME_ZONE_LABEL;
settings.tzFormatT = root["tz_format_t"] | FACTORY_NTP_TIME_ZONE_FORMAT; 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) { // Do not call syncFormatsFromLabels here: readFromFS() uses this updater
settings.tzFormatT += " "; // 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; return StateUpdateResult::CHANGED;
} }
+10
View File
@@ -32,6 +32,12 @@
#define NTP_SETTINGS_SERVICE_PATH "/rest/ntpSettings" #define NTP_SETTINGS_SERVICE_PATH "/rest/ntpSettings"
#define TIME_PATH "/rest/time" #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 { class NTPSettings {
public: public:
bool enabled = FACTORY_NTP_ENABLED; bool enabled = FACTORY_NTP_ENABLED;
@@ -44,6 +50,10 @@ class NTPSettings {
static void read(NTPSettings & settings, JsonObject root); static void read(NTPSettings & settings, JsonObject root);
static StateUpdateResult update(JsonObject root, NTPSettings & settings); 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<NTPSettings> { class NTPSettingsService : public StatefulService<NTPSettings> {
+489
View File
@@ -0,0 +1,489 @@
/* Auto-generated by scripts/generate_tz_zones.py — do not edit. */
#pragma once
#include <stdlib.h>
#include <string.h>
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<const char *>(key), static_cast<const EmsespTzZone *>(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<const EmsespTzZone *>(
bsearch(label, EMSESP_TZ_ZONES, EMSESP_TZ_ZONE_COUNT, sizeof(EmsespTzZone), emsesp_tz_zone_cmp));
return found != nullptr ? found->format : nullptr;
}