From 0660dfb180a5e995e6c4e54219a62299816c8909 Mon Sep 17 00:00:00 2001 From: CyberNative AI LLC Date: Tue, 20 Oct 2026 22:28:10 -0700 Subject: [PATCH 1/2] Time zones: update the list to tz 2026e and refresh the saved code Data/system/timezones.csv: each code is now the POSIX TZ string of IANA tz 2026e (the footer of the zone's TZif file). 10 rows change: Morocco and Western Sahara (UTC+0 since 2026-09-20), Moldova (changes at 03:00/04:00), Paraguay (permanent UTC-3), and British Columbia, Alberta, the Northwest Territories and Manitoba (no fall-back on 2026-11-01). The saved tz_code is a copy of the row from when the zone was picked, so initTimeZone() now replaces it with the bundled row for the saved name. A name that is not in the list keeps its code. --- Data/system/timezones.csv | 20 +++--- .../Private/Tactility/settings/TimePrivate.h | 13 +++- Tactility/Source/settings/time.cpp | 45 +++++++++++- Tactility/Tests/Source/TimeZoneTest.cpp | 69 +++++++++++++++++++ 4 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 Tactility/Tests/Source/TimeZoneTest.cpp diff --git a/Data/system/timezones.csv b/Data/system/timezones.csv index 699ac9542..1bbf681e5 100644 --- a/Data/system/timezones.csv +++ b/Data/system/timezones.csv @@ -11,14 +11,14 @@ "Africa/Brazzaville","WAT-1" "Africa/Bujumbura","CAT-2" "Africa/Cairo","EET-2EEST,M4.5.5/0,M10.5.4/24" -"Africa/Casablanca","<+01>-1" +"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","<+01>-1" +"Africa/El_Aaiun","<+00>0" "Africa/Freetown","GMT0" "Africa/Gaborone","CAT-2" "Africa/Harare","CAT-2" @@ -68,7 +68,7 @@ "America/Argentina/Tucuman","<-03>3" "America/Argentina/Ushuaia","<-03>3" "America/Aruba","AST4" -"America/Asuncion","<-04>4<-03>,M10.1.0/0,M3.4.0/0" +"America/Asuncion","<-03>3" "America/Atikokan","EST5" "America/Bahia","<-03>3" "America/Bahia_Banderas","CST6" @@ -97,7 +97,7 @@ "America/Denver","MST7MDT,M3.2.0,M11.1.0" "America/Detroit","EST5EDT,M3.2.0,M11.1.0" "America/Dominica","AST4" -"America/Edmonton","MST7MDT,M3.2.0,M11.1.0" +"America/Edmonton","CST6" "America/Eirunepe","<-05>5" "America/El_Salvador","CST6" "America/Fortaleza","<-03>3" @@ -122,7 +122,7 @@ "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","MST7MDT,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" @@ -169,7 +169,7 @@ "America/Porto_Velho","<-04>4" "America/Puerto_Rico","AST4" "America/Punta_Arenas","<-03>3" -"America/Rainy_River","CST6CDT,M3.2.0,M11.1.0" +"America/Rainy_River","EST5" "America/Rankin_Inlet","CST6CDT,M3.2.0,M11.1.0" "America/Recife","<-03>3" "America/Regina","CST6" @@ -194,11 +194,11 @@ "America/Tijuana","PST8PDT,M3.2.0,M11.1.0" "America/Toronto","EST5EDT,M3.2.0,M11.1.0" "America/Tortola","AST4" -"America/Vancouver","PST8PDT,M3.2.0,M11.1.0" +"America/Vancouver","MST7" "America/Whitehorse","MST7" -"America/Winnipeg","CST6CDT,M3.2.0,M11.1.0" +"America/Winnipeg","EST5" "America/Yakutat","AKST9AKDT,M3.2.0,M11.1.0" -"America/Yellowknife","MST7MDT,M3.2.0,M11.1.0" +"America/Yellowknife","CST6" "Antarctica/Casey","<+08>-8" "Antarctica/Davis","<+07>-7" "Antarctica/DumontDUrville","<+10>-10" @@ -326,7 +326,7 @@ "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,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" diff --git a/Tactility/Private/Tactility/settings/TimePrivate.h b/Tactility/Private/Tactility/settings/TimePrivate.h index 1968e5e2e..bab8fd6a4 100644 --- a/Tactility/Private/Tactility/settings/TimePrivate.h +++ b/Tactility/Private/Tactility/settings/TimePrivate.h @@ -1,7 +1,18 @@ #pragma once +#include + namespace tt::settings { +/** Applies the saved time zone, first updating its code to the bundled timezones.csv row for its name */ void initTimeZone(); -} +/** + * Find the code of a time zone in the bundled timezones.csv + * @param[in] name the time zone name, e.g. "Europe/Amsterdam" + * @param[out] outCode the code, set only when found + * @return true when found + */ +bool findTimeZoneCode(const std::string& name, std::string& outCode); + +} // namespace tt::settings diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index 1060444b8..260ddd04e 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -11,6 +11,10 @@ #include +#include + +#include + #ifdef ESP_PLATFORM #include #endif @@ -37,11 +41,50 @@ bool getPreferencesPath(std::string& outPath) { return true; } +void saveTimeZoneCode(const std::string& code) { + std::string path; + if (getPreferencesPath(path)) { + Preferences* preferences = preferences_open(path.c_str()); + if (preferences != nullptr) { + preferences_put_string(preferences, TIMEZONE_PREFERENCES_KEY_CODE, code.c_str()); + preferences_close(preferences); + } + } +} + } // namespace +bool findTimeZoneCode(const std::string& name, std::string& outCode) { + const std::string_view csv(reinterpret_cast(asset_timezones_csv), asset_timezones_csv_size); + // Lines look like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" + const std::string prefix = "\"" + name + "\",\""; + size_t start = 0; + while (start < csv.size()) { + size_t end = csv.find('\n', start); + if (end == std::string_view::npos) { + end = csv.size(); + } + const auto line = csv.substr(start, end - start); + if (line.starts_with(prefix) && line.size() > prefix.size() && line.ends_with('"')) { + outCode = line.substr(prefix.size(), line.size() - prefix.size() - 1); + return true; + } + start = end + 1; + } + return false; +} + void initTimeZone() { + auto code = getTimeZoneCode(); + // The saved code is a copy of the timezones.csv row from when the time zone was picked. When a + // country changes its rules, a firmware update ships a new row, so use that for the same name. + std::string bundled_code; + if (hasTimeZone() && findTimeZoneCode(getTimeZoneName(), bundled_code) && bundled_code != code) { + LOG_I(TAG, "Updating the time zone code from %s to %s", code.c_str(), bundled_code.c_str()); + saveTimeZoneCode(bundled_code); + code = bundled_code; + } #ifdef ESP_PLATFORM - auto code= getTimeZoneCode(); if (!code.empty()) { setenv("TZ", code.c_str(), 1); tzset(); diff --git a/Tactility/Tests/Source/TimeZoneTest.cpp b/Tactility/Tests/Source/TimeZoneTest.cpp new file mode 100644 index 000000000..1ee2dd24a --- /dev/null +++ b/Tactility/Tests/Source/TimeZoneTest.cpp @@ -0,0 +1,69 @@ +#include "doctest.h" + +#include +#include + +#include +#include + +#include + +using namespace tt; + +namespace { + +/** Writes the time zone like an older firmware did, without the radio region and event of setTimeZone() */ +void saveTimeZone(const char* name, const char* code) { + char root[128]; + REQUIRE(paths_get_data_path(root, sizeof(root)) == ERROR_NONE); + const std::string path = std::string(root) + "/settings/time.properties"; + Preferences* preferences = preferences_open(path.c_str()); + REQUIRE(preferences != nullptr); + preferences_put_string(preferences, "tz_name", name); + preferences_put_string(preferences, "tz_code", code); + preferences_close(preferences); +} + +} // namespace + +TEST_CASE("findTimeZoneCode finds the bundled code of a time zone") { + std::string code; + CHECK(settings::findTimeZoneCode("Europe/Amsterdam", code)); + CHECK_EQ(code, "CET-1CEST,M3.5.0,M10.5.0/3"); + CHECK(settings::findTimeZoneCode("Africa/Abidjan", code)); // first row + CHECK_EQ(code, "GMT0"); + CHECK(settings::findTimeZoneCode("Etc/Zulu", code)); // last row + CHECK_EQ(code, "UTC0"); +} + +TEST_CASE("findTimeZoneCode only matches whole names") { + std::string code = "unchanged"; + CHECK_FALSE(settings::findTimeZoneCode("Europe/Amster", code)); + CHECK_FALSE(settings::findTimeZoneCode("Amsterdam", code)); + CHECK_FALSE(settings::findTimeZoneCode("", code)); + CHECK_FALSE(settings::findTimeZoneCode("Mars/Olympus_Mons", code)); + CHECK_EQ(code, "unchanged"); + CHECK(settings::findTimeZoneCode("Etc/GMT+1", code)); + CHECK_EQ(code, "<-01>1"); +} + +TEST_CASE("initTimeZone replaces an outdated saved code with the bundled one") { + saveTimeZone("America/Winnipeg", "CST6CDT,M3.2.0,M11.1.0"); + settings::initTimeZone(); + std::string bundled; + REQUIRE(settings::findTimeZoneCode("America/Winnipeg", bundled)); + CHECK_EQ(bundled, "EST5"); // CST all year from 1 November 2026 (IANA tz 2026e) + CHECK_EQ(settings::getTimeZoneCode(), bundled); + CHECK_EQ(settings::getTimeZoneName(), "America/Winnipeg"); +} + +TEST_CASE("initTimeZone keeps a saved code that is current or not in the list") { + saveTimeZone("Europe/Amsterdam", "CET-1CEST,M3.5.0,M10.5.0/3"); + settings::initTimeZone(); + CHECK_EQ(settings::getTimeZoneCode(), "CET-1CEST,M3.5.0,M10.5.0/3"); + + saveTimeZone("Custom/Zone", "<+0530>-5:30"); + settings::initTimeZone(); + CHECK_EQ(settings::getTimeZoneCode(), "<+0530>-5:30"); + CHECK_EQ(settings::getTimeZoneName(), "Custom/Zone"); +} From 0ce47a0bd1e8cf2a70ce3112037ee2259dc8a088 Mon Sep 17 00:00:00 2001 From: CyberNative AI LLC Date: Tue, 27 Oct 2026 21:02:48 -0700 Subject: [PATCH 2/2] Time zones: accept CRLF line endings in timezones.csv findTimeZoneCode() splits the CSV on '\n'. With CRLF line endings, for example from a checkout with core.autocrlf, every line ended in '\r' and no row matched, so the saved code was never refreshed. Drop a trailing '\r' before matching. The search now also takes the CSV text as an argument, so a test can cover CRLF input. --- Tactility/Private/Tactility/settings/TimePrivate.h | 10 ++++++++++ Tactility/Source/settings/time.cpp | 13 ++++++++++--- Tactility/Tests/Source/TimeZoneTest.cpp | 12 ++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Tactility/Private/Tactility/settings/TimePrivate.h b/Tactility/Private/Tactility/settings/TimePrivate.h index bab8fd6a4..f5384de74 100644 --- a/Tactility/Private/Tactility/settings/TimePrivate.h +++ b/Tactility/Private/Tactility/settings/TimePrivate.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace tt::settings { @@ -15,4 +16,13 @@ void initTimeZone(); */ bool findTimeZoneCode(const std::string& name, std::string& outCode); +/** + * Find the code of a time zone in CSV text with the format of timezones.csv (LF or CRLF line endings) + * @param[in] csv the CSV text + * @param[in] name the time zone name, e.g. "Europe/Amsterdam" + * @param[out] outCode the code, set only when found + * @return true when found + */ +bool findTimeZoneCode(std::string_view csv, const std::string& name, std::string& outCode); + } // namespace tt::settings diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index 260ddd04e..66a34af45 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -54,8 +54,7 @@ void saveTimeZoneCode(const std::string& code) { } // namespace -bool findTimeZoneCode(const std::string& name, std::string& outCode) { - const std::string_view csv(reinterpret_cast(asset_timezones_csv), asset_timezones_csv_size); +bool findTimeZoneCode(std::string_view csv, const std::string& name, std::string& outCode) { // Lines look like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" const std::string prefix = "\"" + name + "\",\""; size_t start = 0; @@ -64,7 +63,10 @@ bool findTimeZoneCode(const std::string& name, std::string& outCode) { if (end == std::string_view::npos) { end = csv.size(); } - const auto line = csv.substr(start, end - start); + auto line = csv.substr(start, end - start); + if (line.ends_with('\r')) { + line.remove_suffix(1); // CRLF line ending + } if (line.starts_with(prefix) && line.size() > prefix.size() && line.ends_with('"')) { outCode = line.substr(prefix.size(), line.size() - prefix.size() - 1); return true; @@ -74,6 +76,11 @@ bool findTimeZoneCode(const std::string& name, std::string& outCode) { return false; } +bool findTimeZoneCode(const std::string& name, std::string& outCode) { + const std::string_view csv(reinterpret_cast(asset_timezones_csv), asset_timezones_csv_size); + return findTimeZoneCode(csv, name, outCode); +} + void initTimeZone() { auto code = getTimeZoneCode(); // The saved code is a copy of the timezones.csv row from when the time zone was picked. When a diff --git a/Tactility/Tests/Source/TimeZoneTest.cpp b/Tactility/Tests/Source/TimeZoneTest.cpp index 1ee2dd24a..60ecec101 100644 --- a/Tactility/Tests/Source/TimeZoneTest.cpp +++ b/Tactility/Tests/Source/TimeZoneTest.cpp @@ -7,6 +7,7 @@ #include #include +#include using namespace tt; @@ -47,6 +48,17 @@ TEST_CASE("findTimeZoneCode only matches whole names") { CHECK_EQ(code, "<-01>1"); } +TEST_CASE("findTimeZoneCode accepts CRLF line endings") { + const std::string_view csv = "\"Africa/Abidjan\",\"GMT0\"\r\n\"America/Winnipeg\",\"EST5\"\r\n\"Etc/Zulu\",\"UTC0\"\r"; + std::string code; + CHECK(settings::findTimeZoneCode(csv, "Africa/Abidjan", code)); + CHECK_EQ(code, "GMT0"); + CHECK(settings::findTimeZoneCode(csv, "America/Winnipeg", code)); + CHECK_EQ(code, "EST5"); + CHECK(settings::findTimeZoneCode(csv, "Etc/Zulu", code)); // last row, no line feed + CHECK_EQ(code, "UTC0"); +} + TEST_CASE("initTimeZone replaces an outdated saved code with the bundled one") { saveTimeZone("America/Winnipeg", "CST6CDT,M3.2.0,M11.1.0"); settings::initTimeZone();