30static const char*
TAG =
"I18n";
35constexpr const char* NVS_KEY_LANG_CODE =
"langc";
40 {
"core.main_menu",
"Main Menu"},
41 {
"core.settings",
"Settings"},
42 {
"core.hardware",
"Hardware"},
43 {
"core.tools",
"Tools"},
44 {
"core.hardware_info",
"Hardware Info"},
45 {
"core.name",
"Name"},
46 {
"core.info",
"Info"},
47 {
"core.info2",
"Info 2"},
48 {
"core.default_name",
"CDC Badge"},
50 {
"core.back",
"Back"},
52 {
"core.cancel",
"Cancel"},
53 {
"core.save",
"Save"},
54 {
"core.delete",
"Delete"},
55 {
"core.edit",
"Edit"},
56 {
"core.view",
"View"},
57 {
"core.select",
"Select"},
58 {
"core.open",
"Open"},
61 {
"core.new_folder",
"New folder"},
62 {
"core.new_file",
"New file"},
63 {
"core.exists",
"Already exists"},
64 {
"core.not_empty",
"Not empty"},
65 {
"core.sure",
"Are you sure?"},
66 {
"core.vfat",
"vFAT"},
71 {
"core.saved",
"Saved"},
72 {
"core.deleted",
"Deleted"},
73 {
"core.failed",
"Failed"},
74 {
"core.timeout",
"Timeout"},
75 {
"core.empty",
"empty"},
77 {
"core.lock",
"Lock"},
78 {
"core.unlock",
"Unlock"},
79 {
"core.enter_pin",
"Enter PIN"},
80 {
"core.press_any_key",
"Any key: unlock [3]: menu"},
81 {
"core.deep_sleep",
"Deep Sleep"},
82 {
"core.wrong_pin",
"Wrong PIN"},
83 {
"core.locked_out",
"Locked out"},
84 {
"core.too_many_attempts",
"Too many attempts"},
86 {
"core.change_pin",
"Change PIN"},
87 {
"core.set_duress_pin",
"Set Duress PIN"},
88 {
"core.current_pin",
"Current PIN"},
89 {
"core.new_pin",
"New PIN"},
90 {
"core.confirm_pin",
"Confirm PIN"},
91 {
"core.pin_changed",
"PIN changed"},
92 {
"core.pins_dont_match",
"PINs don't match"},
93 {
"core.pin_too_short",
"PIN too short"},
94 {
"core.pin_mismatch",
"PINs don't match"},
95 {
"core.retries",
"Retries"},
96 {
"core.error_generic",
"Error"},
98 {
"core.brightness",
"Brightness"},
99 {
"core.language",
"Language"},
100 {
"core.lang_name",
"English"},
101 {
"core.timezone",
"Timezone"},
102 {
"core.summer_time",
"Daylight Saving"},
103 {
"core.badge_text",
"Badge Text"},
104 {
"core.auto_sleep",
"Sleep Interval"},
105 {
"core.set_date",
"Set Date"},
106 {
"core.set_time",
"Set Time"},
107 {
"core.date",
"Date"},
108 {
"core.time",
"Time"},
109 {
"core.date_saved",
"Date saved"},
110 {
"core.time_saved",
"Time saved"},
111 {
"core.modules",
"Modules"},
112 {
"core.never",
"Never"},
113 {
"core.minutes",
"min"},
115 {
"core.wifi_menu",
"WiFi"},
116 {
"core.wifi_setup",
"WiFi Setup"},
117 {
"core.wifi_connect",
"Connect"},
118 {
"core.wifi_details",
"Details"},
119 {
"core.wifi_disconnect",
"Disconnect"},
120 {
"core.wifi_on",
"WiFi ON"},
121 {
"core.wifi_off",
"WiFi OFF"},
122 {
"core.wifi_scanning",
"Scanning..."},
123 {
"core.wifi_no_networks",
"No networks"},
124 {
"core.wifi_connecting",
"Connecting..."},
125 {
"core.wifi_connected",
"Connected!"},
126 {
"core.wifi_disconnected",
"Disconnected"},
127 {
"core.wifi_failed",
"Connection failed"},
128 {
"core.wifi_no_config",
"No WiFi configured"},
129 {
"core.wifi_password",
"Password"},
130 {
"core.wifi_add_manual",
"Add Manual"},
131 {
"core.wifi_ssid",
"SSID"},
132 {
"core.wifi_encryption",
"Encryption"},
133 {
"core.wifi_ip_mode",
"IP Mode"},
134 {
"core.wifi_dhcp",
"DHCP (Auto)"},
135 {
"core.wifi_static",
"Static IP"},
136 {
"core.wifi_gateway",
"Gateway"},
137 {
"core.wifi_netmask",
"Netmask"},
138 {
"core.wifi_dns",
"DNS"},
139 {
"core.wifi_saved_config",
"Saved Config"},
140 {
"core.wifi_signal",
"Signal"},
141 {
"core.ntp_sync",
"Sync Time"},
142 {
"core.ntp_syncing",
"Syncing time..."},
143 {
"core.ntp_success",
"Time synced!"},
144 {
"core.ntp_failed",
"Sync failed"},
145 {
"core.ntp_timeout",
"Sync timeout"},
146 {
"core.bluetooth",
"Bluetooth"},
147 {
"core.bluetooth_on",
"Bluetooth ON"},
148 {
"core.bluetooth_off",
"Bluetooth OFF"},
149 {
"core.ble_status",
"BLE Status"},
150 {
"core.ble_scan",
"Scan Devices"},
151 {
"core.ble_scanning",
"Scanning..."},
152 {
"core.ble_no_devices",
"No devices found"},
153 {
"core.ble_connected_to",
"Connected to"},
154 {
"core.ble_not_connected",
"Not connected"},
155 {
"core.ble_mac_address",
"MAC"},
156 {
"core.ble_signal",
"Signal"},
157 {
"core.ble_paired_devices",
"Paired devices"},
158 {
"core.ble_no_paired",
"No paired devices"},
159 {
"core.ble_forget_one",
"Forget this device?"},
160 {
"core.ble_forget_all",
"Forget all bonds"},
161 {
"core.ble_forget_all_confirm",
"Forget all paired devices?"},
162 {
"core.ble_bonds_cleared",
"Bonds cleared"},
163 {
"core.ble_pairing_menu",
"Pair device"},
164 {
"core.ble_pairing_title",
"Pairing Mode"},
165 {
"core.ble_pairing_instr",
"On your device, select:"},
166 {
"core.ble_pairing_waiting",
"Waiting for device..."},
167 {
"core.ble_pairing_connected",
"Connected"},
168 {
"core.ble_pairing_exit",
"[N] Exit"},
169 {
"core.msg_beacon",
"Beacon"},
170 {
"core.msg_beacon_on",
"Beacon: on"},
171 {
"core.msg_beacon_off",
"Beacon: off"},
172 {
"core.msg_beacon_name",
"Beacon name"},
173 {
"core.msg_beacon_scan",
"Beacon scan"},
174 {
"core.msg_pick_peer",
"Select badge"},
175 {
"core.msg_searching",
"Searching for badges..."},
176 {
"core.msg_offer_title",
"Incoming transfer"},
177 {
"core.msg_offer_from",
"%s wants to send you:"},
178 {
"core.msg_text",
"Text message"},
179 {
"core.msg_data",
"Data"},
180 {
"core.msg_sending",
"Sending..."},
181 {
"core.msg_receiving",
"Receiving..."},
182 {
"core.msg_transfer_ok",
"Transfer complete"},
183 {
"core.msg_transfer_fail",
"Transfer failed"},
184 {
"core.msg_declined",
"Declined"},
185 {
"core.msg_unsupported",
"Type not supported"},
186 {
"core.system_test",
"System Test"},
187 {
"core.tr01_cache_rebuild",
"TR01 Cache Rebuild"},
188 {
"core.tr01_cache_cleanup",
"TR01 Cache Cleanup"},
189 {
"core.expert",
"Expert"},
190 {
"core.expert_warning",
"Caution"},
191 {
"core.bootloader",
"Bootloader"},
192 {
"core.shipping_mode",
"Shipping Mode"},
193 {
"core.shipping_mode_q",
"Enter shipping mode? Disconnects the battery."},
194 {
"core.plugins",
"Plugins"},
195 {
"core.task_working",
"Please wait"},
196 {
"core.sleep",
"Sleep"},
197 {
"core.usb_replug_required",
"USB replug may be needed"},
198 {
"core.usb_no_free_slot",
"No free USB slot - disable a USB module (e.g. GPG) first"},
199 {
"core.module_error_generic",
"Module error"},
200 {
"core.module_retry_prompt",
"Reload module?"},
202 {
"core.backup",
"Backup"},
203 {
"core.backup_export",
"Export"},
204 {
"core.backup_import",
"Import"},
205 {
"core.backup_delete",
"Delete"},
206 {
"core.backup_passphrase",
"Passphrase"},
207 {
"core.backup_confirm_passphrase",
"Confirm passphrase"},
208 {
"core.backup_pass_empty",
"Passphrase required"},
209 {
"core.backup_export_ok",
"Backup saved"},
210 {
"core.backup_none",
"No backup present"},
211 {
"core.backup_import_fail",
"Wrong passphrase or corrupt file"},
212 {
"core.backup_delete_q",
"Delete backup?"},
213 {
"core.backup_summary",
"Restore Summary"},
214 {
"core.backup_imported",
"Imported"},
215 {
"core.backup_failed",
"Failed"},
216 {
"core.backup_modules",
"Modules"},
217 {
"core.backup_skipped",
"Skipped"},
218 {
"core.backup_system",
"System Settings"},
219 {
"core.backup_scope_info",
"No keys are backed up (FIDO2/GPG); only data."},
221 {
"core.hw_section_memory",
"Memory"},
222 {
"core.hw_section_runtime",
"Runtime"},
223 {
"core.hw_i2c_bus",
"I2C Bus"},
224 {
"core.hw_bq25895",
"BQ25895"},
225 {
"core.hw_tca9535",
"TCA9535"},
226 {
"core.hw_display",
"Display"},
227 {
"core.hw_tropic01",
"TROPIC01"},
228 {
"core.hw_tr01_session",
"TR01 Session"},
229 {
"core.hw_tr01_riscv_fw",
"TR01 RISC-V FW"},
230 {
"core.hw_tr01_spect_fw",
"TR01 SPECT FW"},
231 {
"core.hw_tr01_rmem_slot",
"TR01 R-Mem slot"},
232 {
"core.hw_wifi",
"WiFi"},
233 {
"core.hw_ble",
"BLE"},
234 {
"core.hw_heap",
"Heap"},
235 {
"core.hw_psram",
"PSRAM"},
236 {
"core.hw_nvs",
"NVS"},
237 {
"core.hw_entries",
"entries"},
238 {
"core.hw_battery",
"Battery"},
239 {
"core.hw_temp",
"Temp"},
240 {
"core.hw_uptime",
"Uptime"},
241 {
"core.hw_cpu_load",
"CPU Load"},
242 {
"core.hw_charging_suffix",
" (chg)"},
243 {
"core.hw_not_available",
"n/a"},
245 {
"core.actions",
"Actions"},
246 {
"core.light",
"Light"},
248 {
"core.hint_back",
"[N] Back"},
249 {
"core.hint_select",
"[Y] Select"},
250 {
"core.hint_ok_back",
"[Y] OK [N] Back"},
251 {
"core.hint_approve_deny",
"[Y] Approve [N] Deny"},
252 {
"core.hint_brightness",
"<4 6> Adjust [Y] Save"},
253 {
"core.hint_pin_input",
"[0-9] Input [Y] OK"},
254 {
"core.hint_t9_input",
"[0-9] T9 [Y] OK"},
255 {
"core.t9_full",
"Full"},
256 {
"core.hint_list_menu",
"[3] Menu"},
257 {
"core.hint_scroll_back",
"[2/8] Scroll [N] Back"},
258 {
"core.hint_field_nav",
"[4] < [6] >"},
259 {
"core.hint_date_input",
"[0-9] [Y] OK [N] Clear"},
260 {
"core.hint_time_input",
"[0-9] [Y] OK [N] Clear"},
262 {
"core.wifi_connecting",
"Connecting to"},
263 {
"core.plugin_loading",
"Loading"},
264 {
"core.stop",
"Stop"},
265 {
"core.start",
"Start"},
266 {
"core.enable",
"Enable"},
267 {
"core.disable",
"Disable"},
268 {
"core.plugin_enabled",
"Plugin enabled"},
269 {
"core.plugin_disabled",
"Plugin disabled"},
270 {
"core.plugin_bg_running",
"Runs in background"},
271 {
"core.plugin_not_running",
"Not running"},
272 {
"core.hint_plugin_list",
"[Y] Start [3] Menu [N] Back"},
273 {
"core.hint_password_hidden",
"[hold Y] Show [Y] Save"},
274 {
"core.hint_password_revealed",
"[hold Y] Hide [Y] Save"},
276 {
"core.qr_error",
"QR Error"},
277 {
"core.no_data",
"No data"},
280constexpr std::size_t kCoreCount =
281 sizeof(kCoreStrings) /
sizeof(kCoreStrings[0]);
285I18n::I18n() =
default;
293void I18n::registerCoreEnglishTable()
300 registerCoreEnglishTable();
301 loadLanguageFromNvs();
302 LOG_I(
TAG,
"I18n initialized, lang=%s, core=%u entries",
303 currentLang_.c_str(),
static_cast<unsigned>(kCoreCount));
309 if (!entries || count == 0)
return;
310 en_.reserve(en_.size() + count);
311 en_.insert(en_.end(), entries, entries + count);
315bool I18n::sortIfNeeded()
const
317 if (enSorted_)
return true;
318 std::sort(en_.begin(), en_.end(),
320 return std::strcmp(a.key, b.key) < 0;
326const char* I18n::enLookup(
const char* key)
const
329 I18nEntry probe{key,
nullptr};
330 auto it = std::lower_bound(en_.begin(), en_.end(), probe,
331 [](
const I18nEntry& a,
const I18nEntry& b) {
332 return std::strcmp(a.key, b.key) < 0;
334 if (it != en_.end() && std::strcmp(it->key, key) == 0)
return it->en;
338const char* I18n::overlayLookup(
const char* key)
const
340 if (overlayCount_ == 0 || !overlayRefs_)
return nullptr;
341 const OverlayRef* base = overlayRefs_.get();
342 std::size_t lo = 0, hi = overlayCount_;
344 std::size_t mid = lo + (hi - lo) / 2;
345 int c = std::strcmp(base[mid].key, key);
346 if (c == 0)
return base[mid].value;
347 if (c < 0) lo = mid + 1;
else hi = mid;
354 if (!key || currentLang_ ==
"en")
return nullptr;
355 return overlayLookup(key);
361 if (currentLang_ !=
"en") {
362 if (
const char* s = overlayLookup(key))
return s;
364 if (
const char* s = enLookup(key))
return s;
365 static thread_local char missing[64];
366 std::snprintf(missing,
sizeof(missing),
"?%s", key);
372 std::string newLang = (code && *code) ? code :
"en";
373 if (newLang == currentLang_)
return true;
374 currentLang_ = std::move(newLang);
376 overlayBlob_.reset();
377 overlayRefs_.reset();
379 if (currentLang_ !=
"en") loadActiveOverlayFile();
380 if (onChanged_) onChanged_();
383 LOG_I(
TAG,
"Language changed to %s", currentLang_.c_str());
387void I18n::scanAvailableLanguages()
389 overlayLangs_.clear();
393 constexpr const char* kPrefix =
"lang_";
394 constexpr size_t kPrefixLen = 5;
395 constexpr size_t kSuffixLen = 5;
397 struct dirent* ent =
nullptr;
398 while ((ent = readdir(dir)) !=
nullptr) {
399 const char* n = ent->d_name;
400 const size_t len = std::strlen(n);
401 if (len <= kPrefixLen + kSuffixLen)
continue;
402 if (std::strncmp(n, kPrefix, kPrefixLen) != 0)
continue;
403 if (std::strcmp(n + len - kSuffixLen,
".json") != 0)
continue;
405 std::string code(n + kPrefixLen, len - kPrefixLen - kSuffixLen);
406 if (code.empty() || code ==
"en")
continue;
411 const std::string path = std::string(
OVERLAY_DIR) +
"/" + n;
413 std::fseek(fp.get(), 0, SEEK_END);
414 const long size = std::ftell(fp.get());
415 std::fseek(fp.get(), 0, SEEK_SET);
416 if (size > 0 && size <= 1024 * 1024) {
418 if (buf && std::fread(buf.get(), 1, size, fp.get()) ==
static_cast<size_t>(size)) {
419 buf.get()[size] =
'\0';
420 PsramCjsonScope cjson_psram;
421 if (cJSON* root = cJSON_Parse(buf.get())) {
422 cJSON* nm = cJSON_GetObjectItemCaseSensitive(root,
"core.lang_name");
423 if (cJSON_IsString(nm) && nm->valuestring && *nm->valuestring) {
431 overlayLangs_.push_back(std::move(lang));
435 std::sort(overlayLangs_.begin(), overlayLangs_.end(),
436 [](
const OverlayLanguage& a,
const OverlayLanguage& b) {
437 return a.code < b.code;
441bool I18n::loadActiveOverlayFile()
443 overlayBlob_.reset();
444 overlayRefs_.reset();
447 const std::string path =
448 std::string(
OVERLAY_DIR) +
"/lang_" + currentLang_ +
".json";
452 LOG_W(
TAG,
"Overlay file not found: %s", path.c_str());
456 std::fseek(fp.get(), 0, SEEK_END);
457 long size = std::ftell(fp.get());
458 std::fseek(fp.get(), 0, SEEK_SET);
459 if (size <= 0 || size > 1024 * 1024) {
460 LOG_W(
TAG,
"Overlay file size invalid: %ld", size);
466 LOG_E(
TAG,
"Overlay PSRAM allocation failed (%ld bytes)", size);
469 if (std::fread(buf.get(), 1, size, fp.get()) !=
static_cast<size_t>(size)) {
470 LOG_E(
TAG,
"Overlay file read failed");
473 buf.get()[size] =
'\0';
476 PsramCjsonScope cjson_psram;
477 cJSON* root = cJSON_Parse(buf.get());
478 if (!root || !cJSON_IsObject(root)) {
479 LOG_E(
TAG,
"Overlay JSON parse failed near: %s",
480 cJSON_GetErrorPtr() ? cJSON_GetErrorPtr() :
"<unknown>");
481 if (root) cJSON_Delete(root);
487 std::size_t count = 0;
488 std::size_t bytes = 0;
489 for (cJSON* e = root->child; e; e = e->next) {
490 if (!cJSON_IsString(e) || !e->string || !e->valuestring)
continue;
492 bytes += std::strlen(e->string) + 1;
495 if (count == 0) { cJSON_Delete(root);
return true; }
499 if (!blob || !refs) {
500 LOG_E(
TAG,
"Overlay storage PSRAM allocation failed");
506 char* w = blob.get();
508 for (cJSON* e = root->child; e; e = e->next) {
509 if (!cJSON_IsString(e) || !e->string || !e->valuestring)
continue;
511 const std::size_t kl = std::strlen(e->string);
512 refs.get()[idx].key = w;
513 std::memcpy(w, e->string, kl + 1);
515 refs.get()[idx].value = w;
516 std::memcpy(w, val.c_str(), val.size() + 1);
522 std::sort(refs.get(), refs.get() + count,
523 [](
const OverlayRef& a,
const OverlayRef& b) {
524 return std::strcmp(a.key, b.key) < 0;
527 overlayBlob_ = std::move(blob);
528 overlayRefs_ = std::move(refs);
529 overlayCount_ = count;
531 LOG_I(
TAG,
"Overlay '%s' loaded: %u entries (PSRAM)",
532 currentLang_.c_str(),
static_cast<unsigned>(count));
538 scanAvailableLanguages();
541 if (currentLang_ !=
"en") {
542 ok = loadActiveOverlayFile();
544 overlayBlob_.reset();
545 overlayRefs_.reset();
549 LOG_I(
TAG,
"i18n overlay: %u languages available, active='%s'",
550 static_cast<unsigned>(overlayLangs_.size()),
551 currentLang_.c_str());
553 if (onChanged_) onChanged_();
559 if (!code || !*code || std::strcmp(code,
"en") == 0) {
560 const char* en = enLookup(
"core.lang_name");
561 return en ? en :
"English";
563 for (
const auto& l : overlayLangs_) {
564 if (l.code == code)
return l.name.c_str();
569void I18n::loadLanguageFromNvs()
574 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE,
nullptr, &len) != ESP_OK || len == 0)
return;
575 if (len > 8) len = 8;
577 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE, buf, &len) == ESP_OK) {
582void I18n::saveLanguageToNvs()
586 nvs_set_str(nvs, NVS_KEY_LANG_CODE, currentLang_.c_str());
Canonical CP437 <-> Unicode/UTF-8 codec.
Internationalization with English fallbacks in code and overlay translations loaded at runtime from a...
RAII scope that routes cJSON allocations to PSRAM.
Shared RAII wrappers for firmware resources.
CDC Log: logging over TinyUSB CDC and UART.
#define LOG_W(tag, fmt,...)
#define LOG_I(tag, fmt,...)
#define LOG_E(tag, fmt,...)
RAII wrapper for an NVS handle.
static I18n & instance()
Singleton accessor.
const char * overlayTr(const char *key) const
Overlay-only lookup with no English fallback.
bool setLanguageCode(const char *code)
Set the active language by code.
bool loadOverlay()
Rescan available languages and (re)load the active overlay.
void registerEnglishTable(const I18nEntry *entries, std::size_t count)
Append English entries to the lookup table.
const char * languageName(const char *code) const
Display name (endonym) for a language code, for the picker.
static constexpr const char * OVERLAY_DIR
Directory on the plugins FAT holding the per-language files.
bool init()
Initialize and load persisted language code from NVS.
const char * tr(const char *key) const
Look up a translation by key.
std::string fromUtf8(const char *s)
Convert a UTF-8 string to CP437 bytes (unmapped chars dropped).
FilePtr openFile(const char *path, const char *mode) noexcept
Open a FILE* and wrap it in a FilePtr.
PsramUniquePtr< T > psramAlloc(std::size_t count) noexcept
Allocate count elements of T in PSRAM (8-bit capable region).
Centralized key-code constants for cdc_views.
Single English translation entry.
Internationalization singleton.
std::string name
Endonym for the picker (CP437-encoded), e.g. "Deutsch".